diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index b917344959..21a4fea79c 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,6 +16,8 @@ jobs: run: | /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-runtime-resources-test.sh /bin/sh deploy/test-caddyfile-cache.sh test: diff --git a/.gitignore b/.gitignore index 8c7e67f7e2..4ea6ff311b 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ docs/* frontend/coverage/ aicodex output/ + +# Vitest / Vite cache at repo root +.vite/ diff --git a/.goreleaser.simple.yaml b/.goreleaser.simple.yaml index 14f67fd1cb..b25df6e3bd 100644 --- a/.goreleaser.simple.yaml +++ b/.goreleaser.simple.yaml @@ -49,6 +49,7 @@ dockers: use: buildx extra_files: - deploy/docker-entrypoint.sh + - backend/resources build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 41f9a5559b..19adfdf6dc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -65,6 +65,7 @@ dockers: use: buildx extra_files: - deploy/docker-entrypoint.sh + - backend/resources build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -80,6 +81,7 @@ dockers: use: buildx extra_files: - deploy/docker-entrypoint.sh + - backend/resources build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -95,6 +97,7 @@ dockers: use: buildx extra_files: - deploy/docker-entrypoint.sh + - backend/resources build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.version={{ .Version }}" @@ -110,6 +113,7 @@ dockers: use: buildx extra_files: - deploy/docker-entrypoint.sh + - backend/resources build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.version={{ .Version }}" diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index d0d362e052..349c244491 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -34,8 +34,8 @@ ### 开发工具 ```bash -# golangci-lint v2.7 -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7 +# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音) +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9 # pnpm (前端包管理) npm install -g pnpm @@ -47,13 +47,13 @@ npm install -g pnpm | Workflow | 触发条件 | 检查内容 | |----------|----------|----------| -| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.7 | +| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 | | **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit | | **release.yml** | tag `v*` | 构建发布(PR 不触发) | ### CI 要求 -- Go 版本必须是 **1.25.7** +- 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 会在版本校验步骤直接失败。 - 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` ### 本地测试命令 diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index f251d154c6..804fb05e05 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -42,8 +42,9 @@ RUN addgroup -g 1000 sub2api && \ WORKDIR /app -# Copy pre-built binary from GoReleaser +# Copy the pre-built binary and runtime fallback resources from GoReleaser COPY sub2api /app/sub2api +COPY --chown=sub2api:sub2api backend/resources /app/resources # Create data directory RUN mkdir -p /app/data && chown -R sub2api:sub2api /app diff --git a/Makefile b/Makefile index c878526f96..b0bc1b99f6 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,19 @@ .PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical FRONTEND_CRITICAL_VITEST := \ + src/api/__tests__/client.spec.ts \ + src/api/__tests__/tokenRefresh.spec.ts \ + src/api/__tests__/channelMonitorV2.spec.ts \ src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ src/views/auth/__tests__/WechatCallbackView.spec.ts \ src/views/user/__tests__/PaymentView.spec.ts \ src/views/user/__tests__/PaymentResultView.spec.ts \ + src/views/user/__tests__/ChannelStatusView.mode.spec.ts \ src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ - src/views/admin/__tests__/SettingsView.spec.ts + src/views/admin/__tests__/SettingsView.spec.ts \ + src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ + src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ + src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts # 一键编译前后端 build: build-backend build-frontend diff --git a/README.md b/README.md index eb0f4ba3f1..288cdf226e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.26.5-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/) @@ -50,7 +50,7 @@ Please read the following carefully before using this project: APIKEY.FUN -Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: APIKEY to enjoy a permanent 5% discount on all recharges. +Thanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: APIKEY to enjoy up to 5% off on all recharges. @@ -58,11 +58,6 @@ 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! - -AICodeMirror -Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! - - 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) @@ -84,12 +79,6 @@ Please read the following carefully before using this project: - -unity2 -Thanks to Unity2 for sponsoring this project! Unity2 is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. Register to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit. - - - veilx Thanks to Veilx for sponsoring this project! Veilx CDN is purpose-built for large-scale AI API traffic, deeply optimized for relay services and call chains across OpenAI, Claude, Gemini, and scenarios like chat, image generation, embeddings, and streaming — delivering lower latency and higher stability under heavy concurrency. It also offers China three-network optimized return lines, making it ideal for global AI relay platforms, overseas AI SaaS, and cross-border high-concurrency deployments. @@ -102,32 +91,12 @@ Please read the following carefully before using this project: - -666api -Thanks to 666api for sponsoring this project! 666api is an all-in-one platform offering:
-⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing
-    Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)
-🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP
-💰 Invoices supported - - - sui-xiang Thanks to Suixiang AI Gateway for sponsoring this project! Suixiang AI Gateway is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind. - -anpin -Thanks to anpin.ai for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.
-Self-built primary account pool: 1-3s ultra-fast response, supports channel-partner distribution
-Extreme stability: multi-line intelligent routing + redundant backup system, ensuring year-round high-availability operation;
-Model authenticity: no content intervention or secondary filtering — experience the purest, most powerful native model capabilities.
-1:1 top-up, enterprise-grade service with invoicing available. Anpin AI is not just a relay — it's your secure, reliable, and efficient bridge to the frontier world of intelligence. - - - proxy4free Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via this link to get started and easily build more stable and efficient automation workflows. @@ -149,8 +118,8 @@ Model authenticity: no content intervention or secondary filtering — experienc -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. +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. @@ -165,6 +134,36 @@ Model authenticity: no content intervention or secondary filtering — experienc 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. + +Novada +Thanks to Novada for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows. + + + +Qiniu AI +Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via this link — enterprise users get 12 million tokens free, and developers get 3 million tokens free. + + + +FennoAI +Thanks to FennoAI for sponsoring this project! FennoAI is a high-stability, high-performance API relay provider for enterprise R&D teams and developers, compatible with the OpenAI and Anthropic protocols and seamlessly integrating with mainstream AI coding tools such as Codex, Claude Code, and OpenCode. The platform delivers enterprise-grade stability, supporting call volumes of 100 billion tokens per day, and supports business-to-business settlement and invoicing for both domestic and overseas entities to meet enterprise R&D and procurement needs. As an exclusive benefit for Sub2API users, purchase a subscription via the exclusive link to get $50 worth of Coding Plan credit for only $1.99. Referral rewards are also available: invite friends to purchase and earn up to 20% commission — the more you invite, the more you earn. + + + +LanoX AI +Thank you to LanoX AI for sponsoring this project! LanoX AI provides stable, cost-effective global model access services for developers, teams, and enterprises. 🎁 New User Benefits — Claim millions of free tokens, plus 500+ free models for easier low-cost testing, validation, and deployment 🧠 Global Leading Models — GPT · Claude · Gemini · Qwen · Grok... 🎬 Multimodal Creation — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ Enterprise-Grade Reliability — High availability 💎 native capability output 💎 no intelligence degradation 💎 no model mixing 💎 transparent usage and billing 💎 💰 Lower API Costs — Top-tier models from as low as 10% of official pricing, with clear documentation, simple integration, invoicing support, and enterprise-scale batch usage 🏢 Enterprise Choice — Ideal for AI products, Agents, content platforms, and R&D teams with high-volume model usage + + + +RapidProxy +RapidProxy is a data collection proxy solution built for developers, providing stable and reliable residential proxy services. With 90M+ global residential IPs and 200+ country coverage, intelligent rotation, and precise geo-targeting, it helps projects such as web scraping, AI data training, SEO monitoring, and e-commerce data analysis break through access restrictions and improve data collection efficiency. It supports mainstream automation frameworks such as Playwright, Selenium, and Puppeteer, with prices as low as $0.65/GB — start your free test now. + + + +hao.ai +hao.ai is a high-speed, stable unified large-model API gateway for developers and teams. With a single API Key and a unified interface, you can access mainstream models such as GPT, Claude, and xAI Grok, with compatibility for common protocols and SDKs including OpenAI and Anthropic. The platform provides model routing, failover, team management, and complete request logs, with model prices as low as 15% of official reference pricing, helping users build AI applications more simply, more reliably, and at lower cost. + + ## Overview @@ -197,7 +196,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.25.7, Gin, Ent | +| Backend | Go 1.26.5, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | @@ -743,7 +742,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T | `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` | | `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` | | `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) | -| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` | +| `XAI_GROK_CLI_VERSION` | `0.2.114`; optional override for the client identity sent to `cli-chat-proxy.grok.com`. The pinned value is also the floor: an override below it is dropped | Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API: diff --git a/README_CN.md b/README_CN.md index db4ac7e085..73506c3dfe 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.26.5-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/) @@ -51,7 +51,7 @@ APIKEY.FUN -感谢 APIKEY.FUN 赞助了本项目!APIKEY.FUN 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 APIKEY 注册,可享受所有充值永久 95 折优惠。 +感谢 APIKEY.FUN 赞助了本项目!APIKEY.FUN 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 APIKEY 注册,可享受充值最高 95 折优惠。 @@ -59,11 +59,6 @@ 感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过此链接注册,首次充值可额外获得 10% 赠送额度! - -AICodeMirror -感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过此链接注册,首次充值立享 8 折优惠,企业客户最高可享 75 折! - - bmoplus 感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! @@ -87,12 +82,6 @@ - -unity2 -感谢 Unity2 赞助本项目! Unity2 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。注册链接 - - - veilx 感谢 Veilx 赞助本项目! Veilx CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。购买地址 @@ -105,32 +94,12 @@ - -666api -感谢 666api 赞助本项目!666api 是一站式综合服务平台,提供:
-⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠
-    独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)
-🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP
-💰 支持开票 - - - sui-xiang 感谢 随想AI网关 赞助本项目!随想AI网关 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。 - -anpin -感谢 anpin.ai 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。
-自建一手号池:1-3S超快响应 支持同行分发
-极致稳定:多线智能路由 + 冗余备份系统,确保服务全年无休、高可用运行;
-模型真实性:不做任何内容干预与二次过滤,让您体验到最纯粹、最强大的原生模型能力。
-充值1:1 企业级服务可开票,安品Ai不只是中转站,更是您连接前沿智能世界的安全、可靠、高效桥梁 - - - proxy4free 感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过此链接注册即可开始体验,轻松构建更稳定、高效的自动化工作流。 @@ -152,8 +121,8 @@ -claudeapi -感谢 Claude API 对本项目的赞助! Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过[专属链接]注册后联系客服,可领取免费测试额度,并支持开票和团队对接。 +claudeapi +感谢 Claude API 对本项目的赞助! Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过[专属链接]注册后联系客服,可领取免费测试额度,并支持开票和团队对接。 @@ -168,6 +137,36 @@ Nagora 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。 + +Novada +感谢 Novada 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。 + + + +七牛云AI +感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过此链接注册,企业用户免费领1200万Token,开发者免费领300万Token。 + + + +FennoAI +感谢 FennoAI 赞助本项目!FennoAI 是一家面向企业研发团队和开发者的高稳定、高性能 API 中转服务商,兼容 OpenAI 与 Anthropic 协议,可无缝接入 Codex、Claude Code、OpenCode 等主流 AI 编程工具。平台具备企业级稳定性,可支撑千亿 Token/日的调用规模,并支持境内外主体公对公结算及开票,满足企业级研发与采购需求。作为 Sub2API 用户专属福利,通过专属链接购买订阅,仅需 1.99 美元即可获得价值 50 美元的 Coding Plan 额度。同时支持邀请奖励,邀请好友购买最高可获得 20% 返佣,邀请越多,奖励越高。 + + + +LanoX AI +感谢 LanoX AI 对本项目的赞助!LanoX AI 为开发者、团队与企业提供稳定、高性价比的全球模型接入服务。 🎁 新用户福利 — 免费领取 百万 Token ,更有500+ 免费模型 — 低成本测试、验证、部署更轻松 🧠 全球主流模型 — GPT · Claude · Gemini · Qwen · Grok... 🎬 多模态创作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ 企业级稳定服务 — 高可用💎原生能力输出💎不降智💎不混模💎调用与计费透明💎 💰 更低调用成本 — 顶级模型低至官方价 1 折起,文档清晰、接入简单、支持开票与企业批量调用 🏢 企业优选 — 适用于 AI 产品、Agent、内容平台、研发团队批量调用 + + + +RapidProxy +RapidProxy 是面向开发者的数据采集代理解决方案,提供稳定可靠的住宅代理服务。通过 9000 万+全球住宅 IP和 200+国家覆盖、智能轮换机制和精准地区定位能力,帮助爬虫、AI 数据训练、SEO 监控、电商数据分析等项目突破访问限制,提高数据采集效率。支持 Playwright、Selenium、Puppeteer 等主流自动化框架,价格低至 $0.65/GB,立即免费测试吧。 + + + +hao.ai +hao.ai 是面向开发者与团队的高速、稳定大模型统一 API 网关。通过一个 API Key 和统一接口,即可接入 GPT、Claude、xAI Grok 等主流模型,兼容 OpenAI、Anthropic 等常用协议与 SDK。平台提供模型路由、故障回退、团队管理及完整调用日志,模型价格低至官方参考价的 1.5 折,帮助用户更简单、更稳定、更低成本地构建 AI 应用。 + + ## 项目概述 @@ -199,7 +198,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.25.7, Gin, Ent | +| 后端 | Go 1.26.5, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/README_JA.md b/README_JA.md index c08ded41b6..04362957d5 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.26.5-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,11 +58,6 @@ AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:こちらのリンクから登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント! - -AICodeMirror -AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:こちらのリンクから登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ! - - bmoplus 本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! @@ -86,12 +81,6 @@ - -unity2 -Unity2 のご支援に感謝します!Unity2 は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。登録リンク - - - veilx Veilx のご支援に感謝します!Veilx CDN は超大規模 API リクエストシナリオ向けに設計されており、AI 中継サービスと AI API 呼び出しチェーンに対して深く最適化されています。高並列・高頻度リクエスト・大容量トラフィックに容易に対応し、開発者と企業により高速で安定した、低レイテンシの加速体験を提供します。OpenAI、Claude、Gemini などの AI インターフェース中継はもちろん、チャット、画像生成、Embedding、ストリーミング出力などの複雑なシナリオでも、Veilx は応答速度と接続安定性を大幅に向上させ、ネットワーク変動によるタイムアウトや失敗を効果的に削減します。さらに、Veilx は中国三大ネットワーク最適化の高速回線を提供しており、中国本土から海外 AI サービスへのアクセス速度と安定性を大幅に向上させます。グローバル AI 中継プラットフォーム、海外 AI SaaS、越境ビジネス、高並列 API システム展開に特に適しています。AI API のために生まれ、あなたの AI 中継サービスをより速く、より安定して、より安心に。購入リンク @@ -104,28 +93,12 @@ - -666api -666api のご支援に感謝します!666api はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応 - - - sui-xiang 随想AI ゲートウェイのご支援に感謝します!随想AI ゲートウェイ は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。 - -anpin -anpin.ai のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。
-自社構築の一次アカウントプール:1-3秒の超高速応答、同業者への配布に対応
-極めて高い安定性:マルチライン・インテリジェントルーティング + 冗長バックアップシステムにより、年中無休の高可用性運用を確保;
-モデルの真正性:一切のコンテンツ介入や二次フィルタリングを行わず、最も純粋で強力なネイティブモデル能力を体験できます。
-チャージ 1:1、エンタープライズグレードサービスは請求書発行可能。安品 AI は単なる中継所ではなく、最先端のインテリジェント世界へと繋がる安全・信頼性・高効率の橋渡し役です。 - - - proxy4free Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。こちらのリンクから登録して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。 @@ -147,8 +120,8 @@ -claudeapi -Claude API のご支援に感謝します!Claude API は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。 +claudeapi +Claude API のご支援に感謝します!Claude API は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。 @@ -163,6 +136,36 @@ Nagoraは、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。 + +Novada +Novada のご支援に感謝します!Novada は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。 + + + +Qiniu AI +七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、こちらのリンクから登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。 + + + +FennoAI +FennoAI のご支援に感謝します!FennoAI は、企業の研究開発チームと開発者向けの高安定・高性能 API 中継サービスプロバイダーです。OpenAI と Anthropic のプロトコルに対応し、Codex、Claude Code、OpenCode などの主要 AI コーディングツールにシームレスに接続できます。エンタープライズ級の安定性を備え、1 日あたり千億規模の Token 呼び出しに対応。国内外法人間の企業間決済と請求書発行もサポートし、企業の研究開発・調達ニーズに応えます。Sub2API ユーザー限定特典として、専用リンクからサブスクリプションを購入すると、わずか 1.99 ドルで 50 ドル相当の Coding Plan クレジットを獲得できます。さらに招待報酬にも対応しており、友達の購入で最大 20% の還元を獲得可能。招待が多いほど、報酬も増えます。 + + + +LanoX AI +本プロジェクトをご支援いただいている LanoX AI に感謝します!LanoX AI は、開発者、チーム、企業向けに、安定性とコストパフォーマンスに優れたグローバルモデル接続サービスを提供しています。 🎁 新規ユーザー特典 — 数百万 Token を無料で獲得可能。さらに 500+ の無料モデルで、低コストなテスト、検証、デプロイをより簡単に 🧠 世界の主要モデル — GPT · Claude · Gemini · Qwen · Grok... 🎬 マルチモーダル制作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ エンタープライズ級の安定性 — 高可用性 💎 ネイティブ能力の出力 💎 性能劣化なし 💎 モデル混在なし 💎 利用量と課金が透明 💎 💰 より低い利用コスト — トップモデルを公式価格の 1 割から利用可能。明確なドキュメント、簡単な接続、請求書発行、企業向け一括利用に対応 🏢 企業に最適 — AI プロダクト、Agent、コンテンツプラットフォーム、大量利用する開発チームに最適 + + + +RapidProxy +RapidProxy は開発者向けのデータ収集プロキシソリューションであり、安定して信頼できる住宅用プロキシサービスを提供します。9,000 万以上のグローバル住宅 IP と 200 以上の国・地域のカバー、インテリジェントなローテーション機構、精密な地域ターゲティング機能により、クローラー、AI データ学習、SEO モニタリング、EC データ分析などのプロジェクトがアクセス制限を突破し、データ収集の効率を高めます。Playwright、Selenium、Puppeteer などの主要な自動化フレームワークに対応し、料金は $0.65/GB から。今すぐ無料でお試しください。 + + + +hao.ai +hao.ai は、開発者とチーム向けの高速で安定した大規模モデル統合 API ゲートウェイです。1 つの API Key と統一されたインターフェースで、GPT、Claude、xAI Grok などの主要モデルに接続でき、OpenAI や Anthropic などの一般的なプロトコルと SDK に対応しています。プラットフォームはモデルルーティング、フェイルオーバー、チーム管理、完全な呼び出しログを提供し、モデル価格は公式参考価格の 1.5 割から。よりシンプルに、より安定して、より低コストに AI アプリケーションを構築できます。 + + ## 概要 @@ -194,7 +197,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.25.7, Gin, Ent | +| バックエンド | Go 1.26.5, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | diff --git a/assets/partners/logos/666api.jpg b/assets/partners/logos/666api.jpg deleted file mode 100644 index bb385b242f..0000000000 Binary files a/assets/partners/logos/666api.jpg and /dev/null differ diff --git a/assets/partners/logos/AICodeMirror.jpg b/assets/partners/logos/AICodeMirror.jpg deleted file mode 100644 index 1c98b2238a..0000000000 Binary files a/assets/partners/logos/AICodeMirror.jpg and /dev/null differ diff --git a/assets/partners/logos/anpin.jpg b/assets/partners/logos/anpin.jpg deleted file mode 100644 index bbd33d30cd..0000000000 Binary files a/assets/partners/logos/anpin.jpg and /dev/null differ diff --git a/assets/partners/logos/fennoai.jpg b/assets/partners/logos/fennoai.jpg new file mode 100644 index 0000000000..b0264594ac Binary files /dev/null and b/assets/partners/logos/fennoai.jpg differ diff --git a/assets/partners/logos/haoai.png b/assets/partners/logos/haoai.png new file mode 100644 index 0000000000..d759879dd9 Binary files /dev/null and b/assets/partners/logos/haoai.png differ diff --git a/assets/partners/logos/haoai.svg b/assets/partners/logos/haoai.svg new file mode 100644 index 0000000000..11bdcadcd8 --- /dev/null +++ b/assets/partners/logos/haoai.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/partners/logos/lanox.jpg b/assets/partners/logos/lanox.jpg new file mode 100644 index 0000000000..fc1db8626f Binary files /dev/null and b/assets/partners/logos/lanox.jpg differ diff --git a/assets/partners/logos/novada.png b/assets/partners/logos/novada.png new file mode 100644 index 0000000000..4947ca6fef Binary files /dev/null and b/assets/partners/logos/novada.png differ diff --git a/assets/partners/logos/qiniu.jpg b/assets/partners/logos/qiniu.jpg new file mode 100644 index 0000000000..eea18deca9 Binary files /dev/null and b/assets/partners/logos/qiniu.jpg differ diff --git a/assets/partners/logos/rapidproxy.jpg b/assets/partners/logos/rapidproxy.jpg new file mode 100644 index 0000000000..43301e93c2 Binary files /dev/null and b/assets/partners/logos/rapidproxy.jpg differ diff --git a/assets/partners/logos/unity2.png b/assets/partners/logos/unity2.png deleted file mode 100644 index f1da2ed1b7..0000000000 Binary files a/assets/partners/logos/unity2.png and /dev/null differ diff --git a/backend/cmd/profit-preview/main.go b/backend/cmd/profit-preview/main.go new file mode 100644 index 0000000000..2c351410c9 --- /dev/null +++ b/backend/cmd/profit-preview/main.go @@ -0,0 +1,237 @@ +// profit-preview 读取生产只读导出的 JSON(分组利润配置、账号倍率与探测状态、 +// 用户覆盖倍率、主力模型清单),复用线上 U/D/阈值判定做五平台离线预演。 +// +// 用法: +// +// go run ./cmd/profit-preview -input dump.json [-assume-enabled] [-json] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type inputGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + RateMultiplier float64 `json:"rate_multiplier"` + SubscriptionType string `json:"subscription_type"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` +} + +type inputAccount struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + RateMultiplier *float64 `json:"rate_multiplier"` + Extra map[string]any `json:"extra"` + ModelMapping map[string]string `json:"model_mapping"` +} + +type inputEntry struct { + Group inputGroup `json:"group"` + Accounts []inputAccount `json:"accounts"` + UserOverrides map[string]*float64 `json:"user_overrides"` + Models []string `json:"models"` +} + +type inputDoc struct { + Groups []inputEntry `json:"groups"` +} + +func main() { + inputPath := flag.String("input", "", "生产只读导出 JSON 路径") + assumeEnabled := flag.Bool("assume-enabled", false, "把当前关闭的支持平台分组按保存配置视为已启用") + jsonOut := flag.Bool("json", false, "以 JSON 输出完整报告(默认输出可读表格)") + flag.Parse() + if *inputPath == "" { + fmt.Fprintln(os.Stderr, "usage: profit-preview -input dump.json [-assume-enabled] [-json]") + os.Exit(2) + } + raw, err := os.ReadFile(*inputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "read input: %v\n", err) + os.Exit(1) + } + inputs, err := parsePreviewInputs(raw, *assumeEnabled) + if err != nil { + fmt.Fprintf(os.Stderr, "parse input: %v\n", err) + os.Exit(1) + } + + evalAt := time.Now() + reports := service.PreviewProfitAdmission(inputs, evalAt) + if len(reports) == 0 { + fmt.Fprintln(os.Stderr, "input produced no preview reports") + os.Exit(1) + } + + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"evaluated_at": evalAt, "reports": reports}); err != nil { + fmt.Fprintf(os.Stderr, "write output: %v\n", err) + os.Exit(1) + } + return + } + + fmt.Printf("利润门预演 @ %s(U=账号倍率;探测状态仅告警)\n", evalAt.Format(time.RFC3339)) + for _, report := range reports { + fmt.Printf("\n== 分组 %d %s [%s] ==\n", report.GroupID, report.GroupName, report.Platform) + fmt.Printf(" 利润门生效=%v 假定启用=%v | 默认 D=%.4f 阈值=%.4f | 最低有效 D=%.4f 阈值=%.4f\n", + report.EffectiveGate, report.AssumedEnabled, + report.DefaultD, report.ThresholdDefault, report.MinEffectiveD, report.ThresholdMinD) + counts := map[string]int{} + for _, v := range report.Verdicts { + counts[v.Class]++ + rate := "-" + if v.AccountRate != nil { + rate = fmt.Sprintf("%.4f", *v.AccountRate) + } + flags := make([]string, 0, 2) + if v.RejectedUnderMinD { + flags = append(flags, "最低有效D下拒绝") + } + if len(v.Warnings) > 0 { + flags = append(flags, strings.Join(v.Warnings, ",")) + } + suffix := "" + if len(flags) > 0 { + suffix = " [" + strings.Join(flags, "; ") + "]" + } + fmt.Printf(" 账号 %-4d %-24s 平台=%-12s U=%-8s 来源=%-19s %s%s\n", + v.AccountID, v.Name, v.Platform, rate, v.RateSource, v.Class, suffix) + } + fmt.Printf(" 分类合计: 准入=%d 利润不足=%d 倍率非法=%d\n", + counts[service.ProfitPreviewClassAdmitted], + counts[service.ProfitPreviewClassRejectedThreshold], + counts[service.ProfitPreviewClassRejectedInvalidRate]) + models := make([]string, 0, len(report.RemainingByModel)) + for model := range report.RemainingByModel { + models = append(models, model) + } + sort.Strings(models) + for _, model := range models { + fmt.Printf(" 模型 %-20s 利润门准入账号: 默认D=%d 最低有效D=%d\n", + model, report.RemainingByModel[model], report.RemainingByModelMinD[model]) + } + for _, model := range modelsWithZeroRemaining(report) { + fmt.Printf(" 警告: 模型 %s 启用后利润门准入账号为 0\n", model) + } + for _, model := range modelsWithZeroRemainingUnderMinD(report) { + fmt.Printf(" 警告: 模型 %s 在最低有效D(存在低倍率用户覆盖)下利润门准入账号为 0\n", model) + } + } +} + +func parsePreviewInputs(raw []byte, assumeEnabled bool) ([]service.ProfitPreviewGroupInput, error) { + var doc inputDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if len(doc.Groups) == 0 { + return nil, fmt.Errorf("input contains no groups; check the export query and target configuration") + } + + inputs := make([]service.ProfitPreviewGroupInput, 0, len(doc.Groups)) + for i, entry := range doc.Groups { + if entry.Group.ID <= 0 || strings.TrimSpace(entry.Group.Platform) == "" { + return nil, fmt.Errorf("invalid group at index %d: id and platform are required", i) + } + group := &service.Group{ + ID: entry.Group.ID, + Name: entry.Group.Name, + Platform: entry.Group.Platform, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: entry.Group.RateMultiplier, + SubscriptionType: entry.Group.SubscriptionType, + ProfitControlEnabled: entry.Group.ProfitControlEnabled, + ProfitMinMargin: entry.Group.ProfitMinMargin, + ProfitSafetyBuffer: entry.Group.ProfitSafetyBuffer, + PeakRateEnabled: entry.Group.PeakRateEnabled, + PeakStart: entry.Group.PeakStart, + PeakEnd: entry.Group.PeakEnd, + PeakRateMultiplier: entry.Group.PeakRateMultiplier, + } + accounts := make([]*service.Account, 0, len(entry.Accounts)) + for _, a := range entry.Accounts { + account := &service.Account{ + ID: a.ID, + Name: a.Name, + Platform: a.Platform, + Type: a.Type, + RateMultiplier: a.RateMultiplier, + Extra: a.Extra, + } + if len(a.ModelMapping) > 0 { + mapping := make(map[string]any, len(a.ModelMapping)) + for k, v := range a.ModelMapping { + mapping[k] = v + } + account.Credentials = map[string]any{"model_mapping": mapping} + } + accounts = append(accounts, account) + } + overrides := make(map[int64]float64, len(entry.UserOverrides)) + for userID, rate := range entry.UserOverrides { + if rate == nil { + continue + } + var id int64 + if _, err := fmt.Sscan(userID, &id); err == nil && id > 0 { + overrides[id] = *rate + } + } + inputs = append(inputs, service.ProfitPreviewGroupInput{ + Group: group, + Accounts: accounts, + UserOverrides: overrides, + Models: entry.Models, + AssumeEnabled: assumeEnabled, + }) + } + return inputs, nil +} + +func modelsWithZeroRemaining(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModel { + if count == 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} + +// modelsWithZeroRemainingUnderMinD 返回默认 D 下仍有准入账号、但在最低有效 D +// 下会归零的模型。最低有效 D 来自分组内最低的用户级倍率覆盖:这些模型对那部分 +// 用户是全黑的,而只看默认 D 的告警完全看不出来。 +// 两档都为 0 的模型由 modelsWithZeroRemaining 报告,这里不重复。 +func modelsWithZeroRemainingUnderMinD(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModelMinD { + if count == 0 && report.RemainingByModel[model] > 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} diff --git a/backend/cmd/profit-preview/main_test.go b/backend/cmd/profit-preview/main_test.go new file mode 100644 index 0000000000..9b6d185c5b --- /dev/null +++ b/backend/cmd/profit-preview/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestParsePreviewInputsIgnoresNullUserOverride(t *testing.T) { + raw := []byte(`{ + "groups": [{ + "group": { + "id": 50, + "name": "preview", + "platform": "openai", + "rate_multiplier": 0.5, + "subscription_type": "standard", + "profit_control_enabled": false, + "profit_min_margin": 0.1, + "profit_safety_buffer": 0 + }, + "accounts": [{ + "id": 1, + "name": "cheap", + "platform": "openai", + "type": "apikey", + "rate_multiplier": 0.2 + }], + "user_overrides": {"40": null, "41": 0.4}, + "models": ["gpt-test"] + }] + }`) + + inputs, err := parsePreviewInputs(raw, true) + require.NoError(t, err) + require.Len(t, inputs, 1) + require.Equal(t, map[int64]float64{41: 0.4}, inputs[0].UserOverrides) + require.True(t, inputs[0].AssumeEnabled) + + report := service.PreviewProfitAdmission(inputs, time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC))[0] + require.InDelta(t, 0.4, report.MinEffectiveD, 1e-12, "null 覆盖不能被解码成 0 倍率") + require.InDelta(t, 0.36, report.ThresholdMinD, 1e-12) +} + +func TestParsePreviewInputsRejectsEmptyGroups(t *testing.T) { + for _, raw := range [][]byte{ + []byte(`{"groups":null}`), + []byte(`{"groups":[]}`), + } { + inputs, err := parsePreviewInputs(raw, false) + require.ErrorContains(t, err, "input contains no groups") + require.Nil(t, inputs) + } +} + +// TestModelsWithZeroRemainingWarnings 钉死两档 D 的归零告警分工: +// 默认 D 归零由 modelsWithZeroRemaining 报告;默认 D 仍有账号但最低有效 D +// (分组内存在更低的用户级倍率覆盖)归零的模型必须单独告警——那些用户的该 +// 模型会全黑,只看默认 D 完全看不出来。两档都为 0 时不重复告警。 +func TestModelsWithZeroRemainingWarnings(t *testing.T) { + report := service.ProfitPreviewGroupReport{ + RemainingByModel: map[string]int{ + "both-zero": 0, + "min-d-zero": 2, + "healthy": 3, + "min-d-zero-alt": 1, + }, + RemainingByModelMinD: map[string]int{ + "both-zero": 0, + "min-d-zero": 0, + "healthy": 3, + "min-d-zero-alt": 0, + }, + } + + if got := modelsWithZeroRemaining(report); len(got) != 1 || got[0] != "both-zero" { + t.Fatalf("默认D归零告警应只覆盖 both-zero,got %v", got) + } + + got := modelsWithZeroRemainingUnderMinD(report) + want := []string{"min-d-zero", "min-d-zero-alt"} + if len(got) != len(want) { + t.Fatalf("最低有效D归零告警不符: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("最低有效D归零告警不符(应按模型名排序): got %v want %v", got, want) + } + } +} diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 2e6e783f7a..d3bfa591c2 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.168 +0.1.173 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 47d3b9ec33..8bd9ac5b16 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -88,6 +88,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -109,6 +110,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, @@ -236,6 +238,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"OpenAICodexVersionSyncService", func() error { + codexVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil @@ -314,7 +320,13 @@ func provideCleanup( } return nil }}, - {"ChannelMonitorRunner", func() error { + {"ChannelMonitorV2Aggregator", 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 dedf06d467..8969bf4411 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -46,10 +46,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } userRepository := repository.NewUserRepository(client, db) - passkeyRepository := repository.NewPasskeyRepository(db) redeemCodeRepository := repository.NewRedeemCodeRepository(client) redisClient := repository.ProvideRedis(configConfig) - passkeySessionStore := repository.NewPasskeySessionStore(redisClient) refreshTokenCache := repository.NewRefreshTokenCache(redisClient) settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) @@ -59,6 +57,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { emailService := service.NewEmailService(settingRepository, emailCache) turnstileVerifier := repository.NewTurnstileVerifier() turnstileService := service.NewTurnstileService(settingService, turnstileVerifier) + tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier() + tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier) + aliyunCaptchaVerifier := repository.NewAliyunCaptchaVerifier() + aliyunCaptchaService := service.NewAliyunCaptchaService(settingService, aliyunCaptchaVerifier) emailQueueService := service.ProvideEmailQueueService(emailService) promoCodeRepository := repository.NewPromoCodeRepository(client) billingCache := repository.NewBillingCache(redisClient) @@ -80,11 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService) - authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) - passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository) - if err != nil { - return nil, err - } + authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) @@ -98,7 +96,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService) - passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService) userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) @@ -152,7 +149,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) grokOAuthClient := repository.NewGrokOAuthClient() - grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient) + grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient) grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) @@ -177,8 +174,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository) announcementHandler := handler.NewAnnouncementHandler(announcementService) channelMonitorRepository := repository.NewChannelMonitorRepository(client, db) - channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) + channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor, settingService) channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) + channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db) + channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService) + channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service) dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) @@ -196,11 +196,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) grokQuotaFetcher := service.NewGrokQuotaFetcher() - grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository) + 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) + accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService) 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) @@ -213,7 +213,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService) oAuthHandler := admin.NewOAuthHandler(oAuthService) - openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) + openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService) @@ -229,7 +229,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService) - settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) + settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -260,7 +260,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService) contentModerationRepository := repository.NewContentModerationRepository(db) contentModerationHashCache := repository.NewContentModerationHashCache(redisClient) - contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService) + contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, proxyRepository, apiKeyAuthCacheInvalidator, emailService) contentModerationHandler := admin.NewContentModerationHandler(contentModerationService) configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig) postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db) @@ -287,6 +287,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) + passkeyRepository := repository.NewPasskeyRepository(db) + passkeySessionStore := repository.NewPasskeySessionStore(redisClient) + passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository) + if err != nil { + return nil, err + } + passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService) handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) @@ -304,7 +311,7 @@ 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, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + 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) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -320,14 +327,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) + 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) + 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, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + 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) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -371,6 +380,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + codexVersionSync *service.OpenAICodexVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -392,6 +402,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator, quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, @@ -518,6 +529,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"OpenAICodexVersionSyncService", func() error { + codexVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil @@ -596,6 +611,12 @@ func provideCleanup( } return nil }}, + {"ChannelMonitorV2Aggregator", 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_test.go b/backend/cmd/server/wire_gen_test.go index 592177be07..dff3a9129e 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, ) accountExpirySvc := service.NewAccountExpiryService(nil, time.Second) + codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second) proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second) subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second) pricingSvc := service.NewPricingService(cfg, nil) @@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { schedulerSnapshotSvc, tokenRefreshSvc, accountExpirySvc, + codexVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, @@ -86,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // backupSvc nil, // paymentOrderExpiry nil, // channelMonitorRunner + nil, // channelMonitorV2Aggregator nil, // quotaFlusher nil, // upstreamBillingProbe nil, // ollamaCloudUsage diff --git a/backend/ent/client.go b/backend/ent/client.go index 174d0fb2fd..7cdb7394fd 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -6828,25 +6828,25 @@ type ( APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity, AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob, ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory, - ChannelMonitorRequestTemplate, CompositeModelRoute, - ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision, - PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession, - PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, - SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, - UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, - UserPlatformQuota, UserSubscription []ent.Hook + ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule, + Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog, + PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, + TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, + UserAttributeDefinition, UserAttributeValue, UserPlatformQuota, + UserSubscription []ent.Hook } inters struct { APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity, AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob, ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory, - ChannelMonitorRequestTemplate, CompositeModelRoute, - ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision, - PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession, - PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, - SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, - UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, - UserPlatformQuota, UserSubscription []ent.Interceptor + ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule, + Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog, + PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, + TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, + UserAttributeDefinition, UserAttributeValue, UserPlatformQuota, + UserSubscription []ent.Interceptor } ) diff --git a/backend/ent/group.go b/backend/ent/group.go index d915740125..3b1d2a4a51 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -85,8 +85,18 @@ type Group struct { VideoPrice720p *float64 `json:"video_price_720p,omitempty"` // VideoPrice1080p holds the value of the "video_price_1080p" field. VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"` + // 按模型族和分辨率覆盖视频每秒价格 + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次) WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` + // 搜索工具价格 per 1000 calls(web_search 等) + SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"` + // Voice realtime 每分钟价格(USD) + AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"` + // TTS 每百万字符价格(USD) + AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` + // STT 每小时价格(USD) + AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID @@ -123,6 +133,12 @@ type Group struct { MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // OpenAI reasoning effort 自定义精确映射;先映射再应用上限 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"` + // 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池 + ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"` + // 最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer) + ProfitMinMargin float64 `json:"profit_min_margin,omitempty"` + // 安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0 + ProfitSafetyBuffer float64 `json:"profit_safety_buffer,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -229,11 +245,11 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: + case group.FieldVideoModelPrices, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) - case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: + case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall: + case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -472,6 +488,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.VideoPrice1080p = new(float64) *_m.VideoPrice1080p = value.Float64 } + case group.FieldVideoModelPrices: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field video_model_prices", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.VideoModelPrices); err != nil { + return fmt.Errorf("unmarshal field video_model_prices: %w", err) + } + } case group.FieldWebSearchPricePerCall: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i]) @@ -479,6 +503,34 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.WebSearchPricePerCall = new(float64) *_m.WebSearchPricePerCall = value.Float64 } + case group.FieldSearchPricePer1k: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field search_price_per_1k", values[i]) + } else if value.Valid { + _m.SearchPricePer1k = new(float64) + *_m.SearchPricePer1k = value.Float64 + } + case group.FieldAudioRealtimePricePerMin: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field audio_realtime_price_per_min", values[i]) + } else if value.Valid { + _m.AudioRealtimePricePerMin = new(float64) + *_m.AudioRealtimePricePerMin = value.Float64 + } + case group.FieldAudioTtsPricePerMillionChars: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field audio_tts_price_per_million_chars", values[i]) + } else if value.Valid { + _m.AudioTtsPricePerMillionChars = new(float64) + *_m.AudioTtsPricePerMillionChars = value.Float64 + } + case group.FieldAudioSttPricePerHour: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field audio_stt_price_per_hour", values[i]) + } else if value.Valid { + _m.AudioSttPricePerHour = new(float64) + *_m.AudioSttPricePerHour = value.Float64 + } case group.FieldClaudeCodeOnly: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field claude_code_only", values[i]) @@ -599,6 +651,24 @@ func (_m *Group) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field reasoning_effort_mappings: %w", err) } } + case group.FieldProfitControlEnabled: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field profit_control_enabled", values[i]) + } else if value.Valid { + _m.ProfitControlEnabled = value.Bool + } + case group.FieldProfitMinMargin: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field profit_min_margin", values[i]) + } else if value.Valid { + _m.ProfitMinMargin = value.Float64 + } + case group.FieldProfitSafetyBuffer: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field profit_safety_buffer", values[i]) + } else if value.Valid { + _m.ProfitSafetyBuffer = value.Float64 + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -798,11 +868,34 @@ func (_m *Group) String() string { builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + builder.WriteString("video_model_prices=") + builder.WriteString(fmt.Sprintf("%v", _m.VideoModelPrices)) + builder.WriteString(", ") if v := _m.WebSearchPricePerCall; v != nil { builder.WriteString("web_search_price_per_call=") builder.WriteString(fmt.Sprintf("%v", *v)) } builder.WriteString(", ") + if v := _m.SearchPricePer1k; v != nil { + builder.WriteString("search_price_per_1k=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.AudioRealtimePricePerMin; v != nil { + builder.WriteString("audio_realtime_price_per_min=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.AudioTtsPricePerMillionChars; v != nil { + builder.WriteString("audio_tts_price_per_million_chars=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.AudioSttPricePerHour; v != nil { + builder.WriteString("audio_stt_price_per_hour=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") builder.WriteString("claude_code_only=") builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly)) builder.WriteString(", ") @@ -860,6 +953,15 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("reasoning_effort_mappings=") builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings)) + builder.WriteString(", ") + builder.WriteString("profit_control_enabled=") + builder.WriteString(fmt.Sprintf("%v", _m.ProfitControlEnabled)) + builder.WriteString(", ") + builder.WriteString("profit_min_margin=") + builder.WriteString(fmt.Sprintf("%v", _m.ProfitMinMargin)) + builder.WriteString(", ") + builder.WriteString("profit_safety_buffer=") + builder.WriteString(fmt.Sprintf("%v", _m.ProfitSafetyBuffer)) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 6ec01aed53..76a07b96a0 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -82,8 +82,18 @@ const ( FieldVideoPrice720p = "video_price_720p" // FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database. FieldVideoPrice1080p = "video_price_1080p" + // FieldVideoModelPrices holds the string denoting the video_model_prices field in the database. + FieldVideoModelPrices = "video_model_prices" // FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database. FieldWebSearchPricePerCall = "web_search_price_per_call" + // FieldSearchPricePer1k holds the string denoting the search_price_per_1k field in the database. + FieldSearchPricePer1k = "search_price_per_1k" + // FieldAudioRealtimePricePerMin holds the string denoting the audio_realtime_price_per_min field in the database. + FieldAudioRealtimePricePerMin = "audio_realtime_price_per_min" + // FieldAudioTtsPricePerMillionChars holds the string denoting the audio_tts_price_per_million_chars field in the database. + FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars" + // FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database. + FieldAudioSttPricePerHour = "audio_stt_price_per_hour" // FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database. FieldClaudeCodeOnly = "claude_code_only" // FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database. @@ -120,6 +130,12 @@ const ( FieldMaxReasoningEffort = "max_reasoning_effort" // FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database. FieldReasoningEffortMappings = "reasoning_effort_mappings" + // FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database. + FieldProfitControlEnabled = "profit_control_enabled" + // FieldProfitMinMargin holds the string denoting the profit_min_margin field in the database. + FieldProfitMinMargin = "profit_min_margin" + // FieldProfitSafetyBuffer holds the string denoting the profit_safety_buffer field in the database. + FieldProfitSafetyBuffer = "profit_safety_buffer" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -228,7 +244,12 @@ var Columns = []string{ FieldVideoPrice480p, FieldVideoPrice720p, FieldVideoPrice1080p, + FieldVideoModelPrices, FieldWebSearchPricePerCall, + FieldSearchPricePer1k, + FieldAudioRealtimePricePerMin, + FieldAudioTtsPricePerMillionChars, + FieldAudioSttPricePerHour, FieldClaudeCodeOnly, FieldFallbackGroupID, FieldFallbackGroupIDOnInvalidRequest, @@ -247,6 +268,9 @@ var Columns = []string{ FieldRpmLimit, FieldMaxReasoningEffort, FieldReasoningEffortMappings, + FieldProfitControlEnabled, + FieldProfitMinMargin, + FieldProfitSafetyBuffer, } var ( @@ -332,6 +356,14 @@ var ( DefaultVideoRateIndependent bool // DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field. DefaultVideoRateMultiplier float64 + // SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. + SearchPricePer1kValidator func(float64) error + // AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. + AudioRealtimePricePerMinValidator func(float64) error + // AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. + AudioTtsPricePerMillionCharsValidator func(float64) error + // AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. + AudioSttPricePerHourValidator func(float64) error // DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field. DefaultClaudeCodeOnly bool // DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field. @@ -366,6 +398,12 @@ var ( MaxReasoningEffortValidator func(string) error // DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field. DefaultReasoningEffortMappings []domain.ReasoningEffortMapping + // DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field. + DefaultProfitControlEnabled bool + // DefaultProfitMinMargin holds the default value on creation for the "profit_min_margin" field. + DefaultProfitMinMargin float64 + // DefaultProfitSafetyBuffer holds the default value on creation for the "profit_safety_buffer" field. + DefaultProfitSafetyBuffer float64 ) // OrderOption defines the ordering options for the Group queries. @@ -546,6 +584,26 @@ func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc() } +// BySearchPricePer1k orders the results by the search_price_per_1k field. +func BySearchPricePer1k(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSearchPricePer1k, opts...).ToFunc() +} + +// ByAudioRealtimePricePerMin orders the results by the audio_realtime_price_per_min field. +func ByAudioRealtimePricePerMin(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAudioRealtimePricePerMin, opts...).ToFunc() +} + +// ByAudioTtsPricePerMillionChars orders the results by the audio_tts_price_per_million_chars field. +func ByAudioTtsPricePerMillionChars(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAudioTtsPricePerMillionChars, opts...).ToFunc() +} + +// ByAudioSttPricePerHour orders the results by the audio_stt_price_per_hour field. +func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc() +} + // ByClaudeCodeOnly orders the results by the claude_code_only field. func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc() @@ -611,6 +669,21 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc() } +// ByProfitControlEnabled orders the results by the profit_control_enabled field. +func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc() +} + +// ByProfitMinMargin orders the results by the profit_min_margin field. +func ByProfitMinMargin(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProfitMinMargin, opts...).ToFunc() +} + +// ByProfitSafetyBuffer orders the results by the profit_safety_buffer field. +func ByProfitSafetyBuffer(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProfitSafetyBuffer, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 4ad100573c..630ec582e8 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -225,6 +225,26 @@ func WebSearchPricePerCall(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) } +// SearchPricePer1k applies equality check predicate on the "search_price_per_1k" field. It's identical to SearchPricePer1kEQ. +func SearchPricePer1k(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v)) +} + +// AudioRealtimePricePerMin applies equality check predicate on the "audio_realtime_price_per_min" field. It's identical to AudioRealtimePricePerMinEQ. +func AudioRealtimePricePerMin(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v)) +} + +// AudioTtsPricePerMillionChars applies equality check predicate on the "audio_tts_price_per_million_chars" field. It's identical to AudioTtsPricePerMillionCharsEQ. +func AudioTtsPricePerMillionChars(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioSttPricePerHour applies equality check predicate on the "audio_stt_price_per_hour" field. It's identical to AudioSttPricePerHourEQ. +func AudioSttPricePerHour(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v)) +} + // ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ. func ClaudeCodeOnly(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) @@ -290,6 +310,21 @@ func MaxReasoningEffort(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v)) } +// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ. +func ProfitControlEnabled(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v)) +} + +// ProfitMinMargin applies equality check predicate on the "profit_min_margin" field. It's identical to ProfitMinMarginEQ. +func ProfitMinMargin(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v)) +} + +// ProfitSafetyBuffer applies equality check predicate on the "profit_safety_buffer" field. It's identical to ProfitSafetyBufferEQ. +func ProfitSafetyBuffer(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -1750,6 +1785,16 @@ func VideoPrice1080pNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p)) } +// VideoModelPricesIsNil applies the IsNil predicate on the "video_model_prices" field. +func VideoModelPricesIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldVideoModelPrices)) +} + +// VideoModelPricesNotNil applies the NotNil predicate on the "video_model_prices" field. +func VideoModelPricesNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldVideoModelPrices)) +} + // WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field. func WebSearchPricePerCallEQ(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v)) @@ -1800,6 +1845,206 @@ func WebSearchPricePerCallNotNil() predicate.Group { return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall)) } +// SearchPricePer1kEQ applies the EQ predicate on the "search_price_per_1k" field. +func SearchPricePer1kEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kNEQ applies the NEQ predicate on the "search_price_per_1k" field. +func SearchPricePer1kNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kIn applies the In predicate on the "search_price_per_1k" field. +func SearchPricePer1kIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldSearchPricePer1k, vs...)) +} + +// SearchPricePer1kNotIn applies the NotIn predicate on the "search_price_per_1k" field. +func SearchPricePer1kNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldSearchPricePer1k, vs...)) +} + +// SearchPricePer1kGT applies the GT predicate on the "search_price_per_1k" field. +func SearchPricePer1kGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kGTE applies the GTE predicate on the "search_price_per_1k" field. +func SearchPricePer1kGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kLT applies the LT predicate on the "search_price_per_1k" field. +func SearchPricePer1kLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kLTE applies the LTE predicate on the "search_price_per_1k" field. +func SearchPricePer1kLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldSearchPricePer1k, v)) +} + +// SearchPricePer1kIsNil applies the IsNil predicate on the "search_price_per_1k" field. +func SearchPricePer1kIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldSearchPricePer1k)) +} + +// SearchPricePer1kNotNil applies the NotNil predicate on the "search_price_per_1k" field. +func SearchPricePer1kNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldSearchPricePer1k)) +} + +// AudioRealtimePricePerMinEQ applies the EQ predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinNEQ applies the NEQ predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinIn applies the In predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldAudioRealtimePricePerMin, vs...)) +} + +// AudioRealtimePricePerMinNotIn applies the NotIn predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldAudioRealtimePricePerMin, vs...)) +} + +// AudioRealtimePricePerMinGT applies the GT predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinGTE applies the GTE predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinLT applies the LT predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinLTE applies the LTE predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldAudioRealtimePricePerMin, v)) +} + +// AudioRealtimePricePerMinIsNil applies the IsNil predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldAudioRealtimePricePerMin)) +} + +// AudioRealtimePricePerMinNotNil applies the NotNil predicate on the "audio_realtime_price_per_min" field. +func AudioRealtimePricePerMinNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldAudioRealtimePricePerMin)) +} + +// AudioTtsPricePerMillionCharsEQ applies the EQ predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsNEQ applies the NEQ predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsIn applies the In predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldAudioTtsPricePerMillionChars, vs...)) +} + +// AudioTtsPricePerMillionCharsNotIn applies the NotIn predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldAudioTtsPricePerMillionChars, vs...)) +} + +// AudioTtsPricePerMillionCharsGT applies the GT predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsGTE applies the GTE predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsLT applies the LT predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsLTE applies the LTE predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldAudioTtsPricePerMillionChars, v)) +} + +// AudioTtsPricePerMillionCharsIsNil applies the IsNil predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldAudioTtsPricePerMillionChars)) +} + +// AudioTtsPricePerMillionCharsNotNil applies the NotNil predicate on the "audio_tts_price_per_million_chars" field. +func AudioTtsPricePerMillionCharsNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldAudioTtsPricePerMillionChars)) +} + +// AudioSttPricePerHourEQ applies the EQ predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourNEQ applies the NEQ predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourIn applies the In predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldAudioSttPricePerHour, vs...)) +} + +// AudioSttPricePerHourNotIn applies the NotIn predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldAudioSttPricePerHour, vs...)) +} + +// AudioSttPricePerHourGT applies the GT predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourGTE applies the GTE predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourLT applies the LT predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourLTE applies the LTE predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldAudioSttPricePerHour, v)) +} + +// AudioSttPricePerHourIsNil applies the IsNil predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldAudioSttPricePerHour)) +} + +// AudioSttPricePerHourNotNil applies the NotNil predicate on the "audio_stt_price_per_hour" field. +func AudioSttPricePerHourNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour)) +} + // ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field. func ClaudeCodeOnlyEQ(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v)) @@ -2190,6 +2435,96 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group { return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v)) } +// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field. +func ProfitControlEnabledEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v)) +} + +// ProfitControlEnabledNEQ applies the NEQ predicate on the "profit_control_enabled" field. +func ProfitControlEnabledNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldProfitControlEnabled, v)) +} + +// ProfitMinMarginEQ applies the EQ predicate on the "profit_min_margin" field. +func ProfitMinMarginEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v)) +} + +// ProfitMinMarginNEQ applies the NEQ predicate on the "profit_min_margin" field. +func ProfitMinMarginNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldProfitMinMargin, v)) +} + +// ProfitMinMarginIn applies the In predicate on the "profit_min_margin" field. +func ProfitMinMarginIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldProfitMinMargin, vs...)) +} + +// ProfitMinMarginNotIn applies the NotIn predicate on the "profit_min_margin" field. +func ProfitMinMarginNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldProfitMinMargin, vs...)) +} + +// ProfitMinMarginGT applies the GT predicate on the "profit_min_margin" field. +func ProfitMinMarginGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldProfitMinMargin, v)) +} + +// ProfitMinMarginGTE applies the GTE predicate on the "profit_min_margin" field. +func ProfitMinMarginGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldProfitMinMargin, v)) +} + +// ProfitMinMarginLT applies the LT predicate on the "profit_min_margin" field. +func ProfitMinMarginLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldProfitMinMargin, v)) +} + +// ProfitMinMarginLTE applies the LTE predicate on the "profit_min_margin" field. +func ProfitMinMarginLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldProfitMinMargin, v)) +} + +// ProfitSafetyBufferEQ applies the EQ predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v)) +} + +// ProfitSafetyBufferNEQ applies the NEQ predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldProfitSafetyBuffer, v)) +} + +// ProfitSafetyBufferIn applies the In predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldProfitSafetyBuffer, vs...)) +} + +// ProfitSafetyBufferNotIn applies the NotIn predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldProfitSafetyBuffer, vs...)) +} + +// ProfitSafetyBufferGT applies the GT predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldProfitSafetyBuffer, v)) +} + +// ProfitSafetyBufferGTE applies the GTE predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldProfitSafetyBuffer, v)) +} + +// ProfitSafetyBufferLT applies the LT predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldProfitSafetyBuffer, v)) +} + +// ProfitSafetyBufferLTE applies the LTE predicate on the "profit_safety_buffer" field. +func ProfitSafetyBufferLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldProfitSafetyBuffer, v)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index d12efb838a..3246b92a50 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -483,6 +483,12 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate { return _c } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_c *GroupCreate) SetVideoModelPrices(v map[string]map[string]float64) *GroupCreate { + _c.mutation.SetVideoModelPrices(v) + return _c +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate { _c.mutation.SetWebSearchPricePerCall(v) @@ -497,6 +503,62 @@ func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate return _c } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_c *GroupCreate) SetSearchPricePer1k(v float64) *GroupCreate { + _c.mutation.SetSearchPricePer1k(v) + return _c +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_c *GroupCreate) SetNillableSearchPricePer1k(v *float64) *GroupCreate { + if v != nil { + _c.SetSearchPricePer1k(*v) + } + return _c +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (_c *GroupCreate) SetAudioRealtimePricePerMin(v float64) *GroupCreate { + _c.mutation.SetAudioRealtimePricePerMin(v) + return _c +} + +// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupCreate { + if v != nil { + _c.SetAudioRealtimePricePerMin(*v) + } + return _c +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (_c *GroupCreate) SetAudioTtsPricePerMillionChars(v float64) *GroupCreate { + _c.mutation.SetAudioTtsPricePerMillionChars(v) + return _c +} + +// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupCreate { + if v != nil { + _c.SetAudioTtsPricePerMillionChars(*v) + } + return _c +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (_c *GroupCreate) SetAudioSttPricePerHour(v float64) *GroupCreate { + _c.mutation.SetAudioSttPricePerHour(v) + return _c +} + +// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate { + if v != nil { + _c.SetAudioSttPricePerHour(*v) + } + return _c +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate { _c.mutation.SetClaudeCodeOnly(v) @@ -725,6 +787,48 @@ func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapp return _c } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (_c *GroupCreate) SetProfitControlEnabled(v bool) *GroupCreate { + _c.mutation.SetProfitControlEnabled(v) + return _c +} + +// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil. +func (_c *GroupCreate) SetNillableProfitControlEnabled(v *bool) *GroupCreate { + if v != nil { + _c.SetProfitControlEnabled(*v) + } + return _c +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (_c *GroupCreate) SetProfitMinMargin(v float64) *GroupCreate { + _c.mutation.SetProfitMinMargin(v) + return _c +} + +// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil. +func (_c *GroupCreate) SetNillableProfitMinMargin(v *float64) *GroupCreate { + if v != nil { + _c.SetProfitMinMargin(*v) + } + return _c +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (_c *GroupCreate) SetProfitSafetyBuffer(v float64) *GroupCreate { + _c.mutation.SetProfitSafetyBuffer(v) + return _c +} + +// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil. +func (_c *GroupCreate) SetNillableProfitSafetyBuffer(v *float64) *GroupCreate { + if v != nil { + _c.SetProfitSafetyBuffer(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -998,6 +1102,18 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultReasoningEffortMappings _c.mutation.SetReasoningEffortMappings(v) } + if _, ok := _c.mutation.ProfitControlEnabled(); !ok { + v := group.DefaultProfitControlEnabled + _c.mutation.SetProfitControlEnabled(v) + } + if _, ok := _c.mutation.ProfitMinMargin(); !ok { + v := group.DefaultProfitMinMargin + _c.mutation.SetProfitMinMargin(v) + } + if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok { + v := group.DefaultProfitSafetyBuffer + _c.mutation.SetProfitSafetyBuffer(v) + } return nil } @@ -1101,6 +1217,26 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.VideoRateMultiplier(); !ok { return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)} } + if v, ok := _c.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } + if v, ok := _c.mutation.AudioRealtimePricePerMin(); ok { + if err := group.AudioRealtimePricePerMinValidator(v); err != nil { + return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} + } + } + if v, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok { + if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil { + return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)} + } + } + if v, ok := _c.mutation.AudioSttPricePerHour(); ok { + if err := group.AudioSttPricePerHourValidator(v); err != nil { + return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)} + } + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)} } @@ -1156,6 +1292,15 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.ReasoningEffortMappings(); !ok { return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)} } + if _, ok := _c.mutation.ProfitControlEnabled(); !ok { + return &ValidationError{Name: "profit_control_enabled", err: errors.New(`ent: missing required field "Group.profit_control_enabled"`)} + } + if _, ok := _c.mutation.ProfitMinMargin(); !ok { + return &ValidationError{Name: "profit_min_margin", err: errors.New(`ent: missing required field "Group.profit_min_margin"`)} + } + if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok { + return &ValidationError{Name: "profit_safety_buffer", err: errors.New(`ent: missing required field "Group.profit_safety_buffer"`)} + } return nil } @@ -1315,10 +1460,30 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value) _node.VideoPrice1080p = &value } + if value, ok := _c.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + _node.VideoModelPrices = value + } if value, ok := _c.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) _node.WebSearchPricePerCall = &value } + if value, ok := _c.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + _node.SearchPricePer1k = &value + } + if value, ok := _c.mutation.AudioRealtimePricePerMin(); ok { + _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) + _node.AudioRealtimePricePerMin = &value + } + if value, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok { + _spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value) + _node.AudioTtsPricePerMillionChars = &value + } + if value, ok := _c.mutation.AudioSttPricePerHour(); ok { + _spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) + _node.AudioSttPricePerHour = &value + } if value, ok := _c.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) _node.ClaudeCodeOnly = value @@ -1391,6 +1556,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value) _node.ReasoningEffortMappings = value } + if value, ok := _c.mutation.ProfitControlEnabled(); ok { + _spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value) + _node.ProfitControlEnabled = value + } + if value, ok := _c.mutation.ProfitMinMargin(); ok { + _spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value) + _node.ProfitMinMargin = value + } + if value, ok := _c.mutation.ProfitSafetyBuffer(); ok { + _spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value) + _node.ProfitSafetyBuffer = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2081,6 +2258,24 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert { return u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsert) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsert { + u.Set(group.FieldVideoModelPrices, v) + return u +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsert) UpdateVideoModelPrices() *GroupUpsert { + u.SetExcluded(group.FieldVideoModelPrices) + return u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsert) ClearVideoModelPrices() *GroupUpsert { + u.SetNull(group.FieldVideoModelPrices) + return u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert { u.Set(group.FieldWebSearchPricePerCall, v) @@ -2105,6 +2300,102 @@ func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert { return u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsert) SetSearchPricePer1k(v float64) *GroupUpsert { + u.Set(group.FieldSearchPricePer1k, v) + return u +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsert) UpdateSearchPricePer1k() *GroupUpsert { + u.SetExcluded(group.FieldSearchPricePer1k) + return u +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsert) AddSearchPricePer1k(v float64) *GroupUpsert { + u.Add(group.FieldSearchPricePer1k, v) + return u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsert) ClearSearchPricePer1k() *GroupUpsert { + u.SetNull(group.FieldSearchPricePer1k) + return u +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (u *GroupUpsert) SetAudioRealtimePricePerMin(v float64) *GroupUpsert { + u.Set(group.FieldAudioRealtimePricePerMin, v) + return u +} + +// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAudioRealtimePricePerMin() *GroupUpsert { + u.SetExcluded(group.FieldAudioRealtimePricePerMin) + return u +} + +// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field. +func (u *GroupUpsert) AddAudioRealtimePricePerMin(v float64) *GroupUpsert { + u.Add(group.FieldAudioRealtimePricePerMin, v) + return u +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (u *GroupUpsert) ClearAudioRealtimePricePerMin() *GroupUpsert { + u.SetNull(group.FieldAudioRealtimePricePerMin) + return u +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsert) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsert { + u.Set(group.FieldAudioTtsPricePerMillionChars, v) + return u +} + +// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAudioTtsPricePerMillionChars() *GroupUpsert { + u.SetExcluded(group.FieldAudioTtsPricePerMillionChars) + return u +} + +// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsert) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsert { + u.Add(group.FieldAudioTtsPricePerMillionChars, v) + return u +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsert) ClearAudioTtsPricePerMillionChars() *GroupUpsert { + u.SetNull(group.FieldAudioTtsPricePerMillionChars) + return u +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (u *GroupUpsert) SetAudioSttPricePerHour(v float64) *GroupUpsert { + u.Set(group.FieldAudioSttPricePerHour, v) + return u +} + +// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAudioSttPricePerHour() *GroupUpsert { + u.SetExcluded(group.FieldAudioSttPricePerHour) + return u +} + +// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field. +func (u *GroupUpsert) AddAudioSttPricePerHour(v float64) *GroupUpsert { + u.Add(group.FieldAudioSttPricePerHour, v) + return u +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert { + u.SetNull(group.FieldAudioSttPricePerHour) + return u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert { u.Set(group.FieldClaudeCodeOnly, v) @@ -2363,6 +2654,54 @@ func (u *GroupUpsert) UpdateReasoningEffortMappings() *GroupUpsert { return u } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (u *GroupUpsert) SetProfitControlEnabled(v bool) *GroupUpsert { + u.Set(group.FieldProfitControlEnabled, v) + return u +} + +// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create. +func (u *GroupUpsert) UpdateProfitControlEnabled() *GroupUpsert { + u.SetExcluded(group.FieldProfitControlEnabled) + return u +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (u *GroupUpsert) SetProfitMinMargin(v float64) *GroupUpsert { + u.Set(group.FieldProfitMinMargin, v) + return u +} + +// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create. +func (u *GroupUpsert) UpdateProfitMinMargin() *GroupUpsert { + u.SetExcluded(group.FieldProfitMinMargin) + return u +} + +// AddProfitMinMargin adds v to the "profit_min_margin" field. +func (u *GroupUpsert) AddProfitMinMargin(v float64) *GroupUpsert { + u.Add(group.FieldProfitMinMargin, v) + return u +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (u *GroupUpsert) SetProfitSafetyBuffer(v float64) *GroupUpsert { + u.Set(group.FieldProfitSafetyBuffer, v) + return u +} + +// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create. +func (u *GroupUpsert) UpdateProfitSafetyBuffer() *GroupUpsert { + u.SetExcluded(group.FieldProfitSafetyBuffer) + return u +} + +// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field. +func (u *GroupUpsert) AddProfitSafetyBuffer(v float64) *GroupUpsert { + u.Add(group.FieldProfitSafetyBuffer, v) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -3034,6 +3373,27 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne { }) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsertOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetVideoModelPrices(v) + }) +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateVideoModelPrices() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoModelPrices() + }) +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsertOne) ClearVideoModelPrices() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoModelPrices() + }) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -3062,6 +3422,118 @@ func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne { }) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsertOne) SetSearchPricePer1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetSearchPricePer1k(v) + }) +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsertOne) AddSearchPricePer1k(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddSearchPricePer1k(v) + }) +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateSearchPricePer1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateSearchPricePer1k() + }) +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsertOne) ClearSearchPricePer1k() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearSearchPricePer1k() + }) +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (u *GroupUpsertOne) SetAudioRealtimePricePerMin(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAudioRealtimePricePerMin(v) + }) +} + +// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field. +func (u *GroupUpsertOne) AddAudioRealtimePricePerMin(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddAudioRealtimePricePerMin(v) + }) +} + +// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAudioRealtimePricePerMin() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioRealtimePricePerMin() + }) +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (u *GroupUpsertOne) ClearAudioRealtimePricePerMin() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioRealtimePricePerMin() + }) +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAudioTtsPricePerMillionChars(v) + }) +} + +// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddAudioTtsPricePerMillionChars(v) + }) +} + +// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAudioTtsPricePerMillionChars() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioTtsPricePerMillionChars() + }) +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertOne) ClearAudioTtsPricePerMillionChars() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioTtsPricePerMillionChars() + }) +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (u *GroupUpsertOne) SetAudioSttPricePerHour(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAudioSttPricePerHour(v) + }) +} + +// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field. +func (u *GroupUpsertOne) AddAudioSttPricePerHour(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddAudioSttPricePerHour(v) + }) +} + +// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAudioSttPricePerHour() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioSttPricePerHour() + }) +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioSttPricePerHour() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -3363,6 +3835,62 @@ func (u *GroupUpsertOne) UpdateReasoningEffortMappings() *GroupUpsertOne { }) } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (u *GroupUpsertOne) SetProfitControlEnabled(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetProfitControlEnabled(v) + }) +} + +// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateProfitControlEnabled() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitControlEnabled() + }) +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (u *GroupUpsertOne) SetProfitMinMargin(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetProfitMinMargin(v) + }) +} + +// AddProfitMinMargin adds v to the "profit_min_margin" field. +func (u *GroupUpsertOne) AddProfitMinMargin(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddProfitMinMargin(v) + }) +} + +// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateProfitMinMargin() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitMinMargin() + }) +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (u *GroupUpsertOne) SetProfitSafetyBuffer(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetProfitSafetyBuffer(v) + }) +} + +// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field. +func (u *GroupUpsertOne) AddProfitSafetyBuffer(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddProfitSafetyBuffer(v) + }) +} + +// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateProfitSafetyBuffer() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitSafetyBuffer() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -4200,6 +4728,27 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk { }) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (u *GroupUpsertBulk) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetVideoModelPrices(v) + }) +} + +// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateVideoModelPrices() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateVideoModelPrices() + }) +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (u *GroupUpsertBulk) ClearVideoModelPrices() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearVideoModelPrices() + }) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { @@ -4228,6 +4777,118 @@ func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk { }) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (u *GroupUpsertBulk) SetSearchPricePer1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetSearchPricePer1k(v) + }) +} + +// AddSearchPricePer1k adds v to the "search_price_per_1k" field. +func (u *GroupUpsertBulk) AddSearchPricePer1k(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddSearchPricePer1k(v) + }) +} + +// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateSearchPricePer1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateSearchPricePer1k() + }) +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (u *GroupUpsertBulk) ClearSearchPricePer1k() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearSearchPricePer1k() + }) +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (u *GroupUpsertBulk) SetAudioRealtimePricePerMin(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAudioRealtimePricePerMin(v) + }) +} + +// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field. +func (u *GroupUpsertBulk) AddAudioRealtimePricePerMin(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddAudioRealtimePricePerMin(v) + }) +} + +// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAudioRealtimePricePerMin() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioRealtimePricePerMin() + }) +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (u *GroupUpsertBulk) ClearAudioRealtimePricePerMin() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioRealtimePricePerMin() + }) +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertBulk) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAudioTtsPricePerMillionChars(v) + }) +} + +// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertBulk) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddAudioTtsPricePerMillionChars(v) + }) +} + +// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAudioTtsPricePerMillionChars() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioTtsPricePerMillionChars() + }) +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (u *GroupUpsertBulk) ClearAudioTtsPricePerMillionChars() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioTtsPricePerMillionChars() + }) +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (u *GroupUpsertBulk) SetAudioSttPricePerHour(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAudioSttPricePerHour(v) + }) +} + +// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field. +func (u *GroupUpsertBulk) AddAudioSttPricePerHour(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddAudioSttPricePerHour(v) + }) +} + +// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAudioSttPricePerHour() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAudioSttPricePerHour() + }) +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.ClearAudioSttPricePerHour() + }) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { @@ -4529,6 +5190,62 @@ func (u *GroupUpsertBulk) UpdateReasoningEffortMappings() *GroupUpsertBulk { }) } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (u *GroupUpsertBulk) SetProfitControlEnabled(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetProfitControlEnabled(v) + }) +} + +// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateProfitControlEnabled() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitControlEnabled() + }) +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (u *GroupUpsertBulk) SetProfitMinMargin(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetProfitMinMargin(v) + }) +} + +// AddProfitMinMargin adds v to the "profit_min_margin" field. +func (u *GroupUpsertBulk) AddProfitMinMargin(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddProfitMinMargin(v) + }) +} + +// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateProfitMinMargin() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitMinMargin() + }) +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (u *GroupUpsertBulk) SetProfitSafetyBuffer(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetProfitSafetyBuffer(v) + }) +} + +// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field. +func (u *GroupUpsertBulk) AddProfitSafetyBuffer(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddProfitSafetyBuffer(v) + }) +} + +// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateProfitSafetyBuffer() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateProfitSafetyBuffer() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 595565df35..de5474e069 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -640,6 +640,18 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate { return _u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_u *GroupUpdate) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdate { + _u.mutation.SetVideoModelPrices(v) + return _u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (_u *GroupUpdate) ClearVideoModelPrices() *GroupUpdate { + _u.mutation.ClearVideoModelPrices() + return _u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate { _u.mutation.ResetWebSearchPricePerCall() @@ -667,6 +679,114 @@ func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate { return _u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_u *GroupUpdate) SetSearchPricePer1k(v float64) *GroupUpdate { + _u.mutation.ResetSearchPricePer1k() + _u.mutation.SetSearchPricePer1k(v) + return _u +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableSearchPricePer1k(v *float64) *GroupUpdate { + if v != nil { + _u.SetSearchPricePer1k(*v) + } + return _u +} + +// AddSearchPricePer1k adds value to the "search_price_per_1k" field. +func (_u *GroupUpdate) AddSearchPricePer1k(v float64) *GroupUpdate { + _u.mutation.AddSearchPricePer1k(v) + return _u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (_u *GroupUpdate) ClearSearchPricePer1k() *GroupUpdate { + _u.mutation.ClearSearchPricePer1k() + return _u +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (_u *GroupUpdate) SetAudioRealtimePricePerMin(v float64) *GroupUpdate { + _u.mutation.ResetAudioRealtimePricePerMin() + _u.mutation.SetAudioRealtimePricePerMin(v) + return _u +} + +// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdate { + if v != nil { + _u.SetAudioRealtimePricePerMin(*v) + } + return _u +} + +// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field. +func (_u *GroupUpdate) AddAudioRealtimePricePerMin(v float64) *GroupUpdate { + _u.mutation.AddAudioRealtimePricePerMin(v) + return _u +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (_u *GroupUpdate) ClearAudioRealtimePricePerMin() *GroupUpdate { + _u.mutation.ClearAudioRealtimePricePerMin() + return _u +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdate) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdate { + _u.mutation.ResetAudioTtsPricePerMillionChars() + _u.mutation.SetAudioTtsPricePerMillionChars(v) + return _u +} + +// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdate { + if v != nil { + _u.SetAudioTtsPricePerMillionChars(*v) + } + return _u +} + +// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdate) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdate { + _u.mutation.AddAudioTtsPricePerMillionChars(v) + return _u +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdate) ClearAudioTtsPricePerMillionChars() *GroupUpdate { + _u.mutation.ClearAudioTtsPricePerMillionChars() + return _u +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (_u *GroupUpdate) SetAudioSttPricePerHour(v float64) *GroupUpdate { + _u.mutation.ResetAudioSttPricePerHour() + _u.mutation.SetAudioSttPricePerHour(v) + return _u +} + +// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdate { + if v != nil { + _u.SetAudioSttPricePerHour(*v) + } + return _u +} + +// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field. +func (_u *GroupUpdate) AddAudioSttPricePerHour(v float64) *GroupUpdate { + _u.mutation.AddAudioSttPricePerHour(v) + return _u +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate { + _u.mutation.ClearAudioSttPricePerHour() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate { _u.mutation.SetClaudeCodeOnly(v) @@ -953,6 +1073,62 @@ func (_u *GroupUpdate) AppendReasoningEffortMappings(v []domain.ReasoningEffortM return _u } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (_u *GroupUpdate) SetProfitControlEnabled(v bool) *GroupUpdate { + _u.mutation.SetProfitControlEnabled(v) + return _u +} + +// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableProfitControlEnabled(v *bool) *GroupUpdate { + if v != nil { + _u.SetProfitControlEnabled(*v) + } + return _u +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (_u *GroupUpdate) SetProfitMinMargin(v float64) *GroupUpdate { + _u.mutation.ResetProfitMinMargin() + _u.mutation.SetProfitMinMargin(v) + return _u +} + +// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableProfitMinMargin(v *float64) *GroupUpdate { + if v != nil { + _u.SetProfitMinMargin(*v) + } + return _u +} + +// AddProfitMinMargin adds value to the "profit_min_margin" field. +func (_u *GroupUpdate) AddProfitMinMargin(v float64) *GroupUpdate { + _u.mutation.AddProfitMinMargin(v) + return _u +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (_u *GroupUpdate) SetProfitSafetyBuffer(v float64) *GroupUpdate { + _u.mutation.ResetProfitSafetyBuffer() + _u.mutation.SetProfitSafetyBuffer(v) + return _u +} + +// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdate { + if v != nil { + _u.SetProfitSafetyBuffer(*v) + } + return _u +} + +// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field. +func (_u *GroupUpdate) AddProfitSafetyBuffer(v float64) *GroupUpdate { + _u.mutation.AddProfitSafetyBuffer(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -1248,6 +1424,26 @@ func (_u *GroupUpdate) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } + if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok { + if err := group.AudioRealtimePricePerMinValidator(v); err != nil { + return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} + } + } + if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok { + if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil { + return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)} + } + } + if v, ok := _u.mutation.AudioSttPricePerHour(); ok { + if err := group.AudioSttPricePerHourValidator(v); err != nil { + return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)} + } + } if v, ok := _u.mutation.DefaultMappedModel(); ok { if err := group.DefaultMappedModelValidator(v); err != nil { return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} @@ -1450,6 +1646,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + } + if _u.mutation.VideoModelPricesCleared() { + _spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON) + } if value, ok := _u.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) } @@ -1459,6 +1661,42 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.WebSearchPricePerCallCleared() { _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) } + if value, ok := _u.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedSearchPricePer1k(); ok { + _spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if _u.mutation.SearchPricePer1kCleared() { + _spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok { + _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok { + _spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) + } + if _u.mutation.AudioRealtimePricePerMinCleared() { + _spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok { + _spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok { + _spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value) + } + if _u.mutation.AudioTtsPricePerMillionCharsCleared() { + _spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioSttPricePerHour(); ok { + _spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok { + _spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) + } + if _u.mutation.AudioSttPricePerHourCleared() { + _spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } @@ -1544,6 +1782,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { sqljson.Append(u, group.FieldReasoningEffortMappings, value) }) } + if value, ok := _u.mutation.ProfitControlEnabled(); ok { + _spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.ProfitMinMargin(); ok { + _spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedProfitMinMargin(); ok { + _spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.ProfitSafetyBuffer(); ok { + _spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok { + _spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2462,6 +2715,18 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne { return _u } +// SetVideoModelPrices sets the "video_model_prices" field. +func (_u *GroupUpdateOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdateOne { + _u.mutation.SetVideoModelPrices(v) + return _u +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (_u *GroupUpdateOne) ClearVideoModelPrices() *GroupUpdateOne { + _u.mutation.ClearVideoModelPrices() + return _u +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne { _u.mutation.ResetWebSearchPricePerCall() @@ -2489,6 +2754,114 @@ func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne { return _u } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (_u *GroupUpdateOne) SetSearchPricePer1k(v float64) *GroupUpdateOne { + _u.mutation.ResetSearchPricePer1k() + _u.mutation.SetSearchPricePer1k(v) + return _u +} + +// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableSearchPricePer1k(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetSearchPricePer1k(*v) + } + return _u +} + +// AddSearchPricePer1k adds value to the "search_price_per_1k" field. +func (_u *GroupUpdateOne) AddSearchPricePer1k(v float64) *GroupUpdateOne { + _u.mutation.AddSearchPricePer1k(v) + return _u +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (_u *GroupUpdateOne) ClearSearchPricePer1k() *GroupUpdateOne { + _u.mutation.ClearSearchPricePer1k() + return _u +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (_u *GroupUpdateOne) SetAudioRealtimePricePerMin(v float64) *GroupUpdateOne { + _u.mutation.ResetAudioRealtimePricePerMin() + _u.mutation.SetAudioRealtimePricePerMin(v) + return _u +} + +// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetAudioRealtimePricePerMin(*v) + } + return _u +} + +// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field. +func (_u *GroupUpdateOne) AddAudioRealtimePricePerMin(v float64) *GroupUpdateOne { + _u.mutation.AddAudioRealtimePricePerMin(v) + return _u +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (_u *GroupUpdateOne) ClearAudioRealtimePricePerMin() *GroupUpdateOne { + _u.mutation.ClearAudioRealtimePricePerMin() + return _u +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdateOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne { + _u.mutation.ResetAudioTtsPricePerMillionChars() + _u.mutation.SetAudioTtsPricePerMillionChars(v) + return _u +} + +// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetAudioTtsPricePerMillionChars(*v) + } + return _u +} + +// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdateOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne { + _u.mutation.AddAudioTtsPricePerMillionChars(v) + return _u +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (_u *GroupUpdateOne) ClearAudioTtsPricePerMillionChars() *GroupUpdateOne { + _u.mutation.ClearAudioTtsPricePerMillionChars() + return _u +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (_u *GroupUpdateOne) SetAudioSttPricePerHour(v float64) *GroupUpdateOne { + _u.mutation.ResetAudioSttPricePerHour() + _u.mutation.SetAudioSttPricePerHour(v) + return _u +} + +// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetAudioSttPricePerHour(*v) + } + return _u +} + +// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field. +func (_u *GroupUpdateOne) AddAudioSttPricePerHour(v float64) *GroupUpdateOne { + _u.mutation.AddAudioSttPricePerHour(v) + return _u +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne { + _u.mutation.ClearAudioSttPricePerHour() + return _u +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne { _u.mutation.SetClaudeCodeOnly(v) @@ -2775,6 +3148,62 @@ func (_u *GroupUpdateOne) AppendReasoningEffortMappings(v []domain.ReasoningEffo return _u } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (_u *GroupUpdateOne) SetProfitControlEnabled(v bool) *GroupUpdateOne { + _u.mutation.SetProfitControlEnabled(v) + return _u +} + +// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableProfitControlEnabled(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetProfitControlEnabled(*v) + } + return _u +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (_u *GroupUpdateOne) SetProfitMinMargin(v float64) *GroupUpdateOne { + _u.mutation.ResetProfitMinMargin() + _u.mutation.SetProfitMinMargin(v) + return _u +} + +// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableProfitMinMargin(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetProfitMinMargin(*v) + } + return _u +} + +// AddProfitMinMargin adds value to the "profit_min_margin" field. +func (_u *GroupUpdateOne) AddProfitMinMargin(v float64) *GroupUpdateOne { + _u.mutation.AddProfitMinMargin(v) + return _u +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (_u *GroupUpdateOne) SetProfitSafetyBuffer(v float64) *GroupUpdateOne { + _u.mutation.ResetProfitSafetyBuffer() + _u.mutation.SetProfitSafetyBuffer(v) + return _u +} + +// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetProfitSafetyBuffer(*v) + } + return _u +} + +// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field. +func (_u *GroupUpdateOne) AddProfitSafetyBuffer(v float64) *GroupUpdateOne { + _u.mutation.AddProfitSafetyBuffer(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -3083,6 +3512,26 @@ func (_u *GroupUpdateOne) check() error { return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)} } } + if v, ok := _u.mutation.SearchPricePer1k(); ok { + if err := group.SearchPricePer1kValidator(v); err != nil { + return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)} + } + } + if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok { + if err := group.AudioRealtimePricePerMinValidator(v); err != nil { + return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)} + } + } + if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok { + if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil { + return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)} + } + } + if v, ok := _u.mutation.AudioSttPricePerHour(); ok { + if err := group.AudioSttPricePerHourValidator(v); err != nil { + return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)} + } + } if v, ok := _u.mutation.DefaultMappedModel(); ok { if err := group.DefaultMappedModelValidator(v); err != nil { return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} @@ -3302,6 +3751,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.VideoPrice1080pCleared() { _spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64) } + if value, ok := _u.mutation.VideoModelPrices(); ok { + _spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value) + } + if _u.mutation.VideoModelPricesCleared() { + _spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON) + } if value, ok := _u.mutation.WebSearchPricePerCall(); ok { _spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value) } @@ -3311,6 +3766,42 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.WebSearchPricePerCallCleared() { _spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64) } + if value, ok := _u.mutation.SearchPricePer1k(); ok { + _spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedSearchPricePer1k(); ok { + _spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value) + } + if _u.mutation.SearchPricePer1kCleared() { + _spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok { + _spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok { + _spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value) + } + if _u.mutation.AudioRealtimePricePerMinCleared() { + _spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok { + _spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok { + _spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value) + } + if _u.mutation.AudioTtsPricePerMillionCharsCleared() { + _spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64) + } + if value, ok := _u.mutation.AudioSttPricePerHour(); ok { + _spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok { + _spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value) + } + if _u.mutation.AudioSttPricePerHourCleared() { + _spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64) + } if value, ok := _u.mutation.ClaudeCodeOnly(); ok { _spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value) } @@ -3396,6 +3887,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) sqljson.Append(u, group.FieldReasoningEffortMappings, value) }) } + if value, ok := _u.mutation.ProfitControlEnabled(); ok { + _spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.ProfitMinMargin(); ok { + _spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedProfitMinMargin(); ok { + _spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value) + } + if value, ok := _u.mutation.ProfitSafetyBuffer(); ok { + _spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok { + _spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 5d78a74338..491f038bc7 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -928,7 +928,12 @@ var ( {Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "search_price_per_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, {Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true}, @@ -947,6 +952,9 @@ var ( {Name: "rpm_limit", Type: field.TypeInt, Default: 0}, {Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""}, {Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "profit_control_enabled", Type: field.TypeBool, Default: false}, + {Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, + {Name: "profit_safety_buffer", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ @@ -982,7 +990,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[42]}, + Columns: []*schema.Column{GroupsColumns[47]}, }, { Name: "idx_groups_duplicate_operation_id_active", @@ -1618,6 +1626,8 @@ var ( {Name: "model", Type: field.TypeString, Size: 100}, {Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100}, {Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100}, + {Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200}, + {Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true}, {Name: "channel_id", Type: field.TypeInt64, Nullable: true}, {Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500}, {Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50}, @@ -1668,31 +1678,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[42]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[43]}, + Columns: []*schema.Column{UsageLogsColumns[45]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[44]}, + Columns: []*schema.Column{UsageLogsColumns[46]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[45]}, + Columns: []*schema.Column{UsageLogsColumns[47]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -1701,32 +1711,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[44]}, + Columns: []*schema.Column{UsageLogsColumns[46]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41]}, + Columns: []*schema.Column{UsageLogsColumns[43]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[42]}, + Columns: []*schema.Column{UsageLogsColumns[44]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[43]}, + Columns: []*schema.Column{UsageLogsColumns[45]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[45]}, + Columns: []*schema.Column{UsageLogsColumns[47]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[42]}, }, { Name: "usagelog_model", @@ -1746,17 +1756,17 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]}, }, { Name: "usagelog_group_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]}, + Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index cf9258ffa9..99e8a2cd95 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -21896,8 +21896,17 @@ type GroupMutation struct { addvideo_price_720p *float64 video_price_1080p *float64 addvideo_price_1080p *float64 + video_model_prices *map[string]map[string]float64 web_search_price_per_call *float64 addweb_search_price_per_call *float64 + search_price_per_1k *float64 + addsearch_price_per_1k *float64 + audio_realtime_price_per_min *float64 + addaudio_realtime_price_per_min *float64 + audio_tts_price_per_million_chars *float64 + addaudio_tts_price_per_million_chars *float64 + audio_stt_price_per_hour *float64 + addaudio_stt_price_per_hour *float64 claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -21922,6 +21931,11 @@ type GroupMutation struct { max_reasoning_effort *string reasoning_effort_mappings *[]domain.ReasoningEffortMapping appendreasoning_effort_mappings []domain.ReasoningEffortMapping + profit_control_enabled *bool + profit_min_margin *float64 + addprofit_min_margin *float64 + profit_safety_buffer *float64 + addprofit_safety_buffer *float64 clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -23717,6 +23731,55 @@ func (m *GroupMutation) ResetVideoPrice1080p() { delete(m.clearedFields, group.FieldVideoPrice1080p) } +// SetVideoModelPrices sets the "video_model_prices" field. +func (m *GroupMutation) SetVideoModelPrices(value map[string]map[string]float64) { + m.video_model_prices = &value +} + +// VideoModelPrices returns the value of the "video_model_prices" field in the mutation. +func (m *GroupMutation) VideoModelPrices() (r map[string]map[string]float64, exists bool) { + v := m.video_model_prices + if v == nil { + return + } + return *v, true +} + +// OldVideoModelPrices returns the old "video_model_prices" 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) OldVideoModelPrices(ctx context.Context) (v map[string]map[string]float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldVideoModelPrices is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldVideoModelPrices requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVideoModelPrices: %w", err) + } + return oldValue.VideoModelPrices, nil +} + +// ClearVideoModelPrices clears the value of the "video_model_prices" field. +func (m *GroupMutation) ClearVideoModelPrices() { + m.video_model_prices = nil + m.clearedFields[group.FieldVideoModelPrices] = struct{}{} +} + +// VideoModelPricesCleared returns if the "video_model_prices" field was cleared in this mutation. +func (m *GroupMutation) VideoModelPricesCleared() bool { + _, ok := m.clearedFields[group.FieldVideoModelPrices] + return ok +} + +// ResetVideoModelPrices resets all changes to the "video_model_prices" field. +func (m *GroupMutation) ResetVideoModelPrices() { + m.video_model_prices = nil + delete(m.clearedFields, group.FieldVideoModelPrices) +} + // SetWebSearchPricePerCall sets the "web_search_price_per_call" field. func (m *GroupMutation) SetWebSearchPricePerCall(f float64) { m.web_search_price_per_call = &f @@ -23787,6 +23850,286 @@ func (m *GroupMutation) ResetWebSearchPricePerCall() { delete(m.clearedFields, group.FieldWebSearchPricePerCall) } +// SetSearchPricePer1k sets the "search_price_per_1k" field. +func (m *GroupMutation) SetSearchPricePer1k(f float64) { + m.search_price_per_1k = &f + m.addsearch_price_per_1k = nil +} + +// SearchPricePer1k returns the value of the "search_price_per_1k" field in the mutation. +func (m *GroupMutation) SearchPricePer1k() (r float64, exists bool) { + v := m.search_price_per_1k + if v == nil { + return + } + return *v, true +} + +// OldSearchPricePer1k returns the old "search_price_per_1k" 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) OldSearchPricePer1k(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSearchPricePer1k is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSearchPricePer1k requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSearchPricePer1k: %w", err) + } + return oldValue.SearchPricePer1k, nil +} + +// AddSearchPricePer1k adds f to the "search_price_per_1k" field. +func (m *GroupMutation) AddSearchPricePer1k(f float64) { + if m.addsearch_price_per_1k != nil { + *m.addsearch_price_per_1k += f + } else { + m.addsearch_price_per_1k = &f + } +} + +// AddedSearchPricePer1k returns the value that was added to the "search_price_per_1k" field in this mutation. +func (m *GroupMutation) AddedSearchPricePer1k() (r float64, exists bool) { + v := m.addsearch_price_per_1k + if v == nil { + return + } + return *v, true +} + +// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field. +func (m *GroupMutation) ClearSearchPricePer1k() { + m.search_price_per_1k = nil + m.addsearch_price_per_1k = nil + m.clearedFields[group.FieldSearchPricePer1k] = struct{}{} +} + +// SearchPricePer1kCleared returns if the "search_price_per_1k" field was cleared in this mutation. +func (m *GroupMutation) SearchPricePer1kCleared() bool { + _, ok := m.clearedFields[group.FieldSearchPricePer1k] + return ok +} + +// ResetSearchPricePer1k resets all changes to the "search_price_per_1k" field. +func (m *GroupMutation) ResetSearchPricePer1k() { + m.search_price_per_1k = nil + m.addsearch_price_per_1k = nil + delete(m.clearedFields, group.FieldSearchPricePer1k) +} + +// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field. +func (m *GroupMutation) SetAudioRealtimePricePerMin(f float64) { + m.audio_realtime_price_per_min = &f + m.addaudio_realtime_price_per_min = nil +} + +// AudioRealtimePricePerMin returns the value of the "audio_realtime_price_per_min" field in the mutation. +func (m *GroupMutation) AudioRealtimePricePerMin() (r float64, exists bool) { + v := m.audio_realtime_price_per_min + if v == nil { + return + } + return *v, true +} + +// OldAudioRealtimePricePerMin returns the old "audio_realtime_price_per_min" 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) OldAudioRealtimePricePerMin(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAudioRealtimePricePerMin is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAudioRealtimePricePerMin requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAudioRealtimePricePerMin: %w", err) + } + return oldValue.AudioRealtimePricePerMin, nil +} + +// AddAudioRealtimePricePerMin adds f to the "audio_realtime_price_per_min" field. +func (m *GroupMutation) AddAudioRealtimePricePerMin(f float64) { + if m.addaudio_realtime_price_per_min != nil { + *m.addaudio_realtime_price_per_min += f + } else { + m.addaudio_realtime_price_per_min = &f + } +} + +// AddedAudioRealtimePricePerMin returns the value that was added to the "audio_realtime_price_per_min" field in this mutation. +func (m *GroupMutation) AddedAudioRealtimePricePerMin() (r float64, exists bool) { + v := m.addaudio_realtime_price_per_min + if v == nil { + return + } + return *v, true +} + +// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field. +func (m *GroupMutation) ClearAudioRealtimePricePerMin() { + m.audio_realtime_price_per_min = nil + m.addaudio_realtime_price_per_min = nil + m.clearedFields[group.FieldAudioRealtimePricePerMin] = struct{}{} +} + +// AudioRealtimePricePerMinCleared returns if the "audio_realtime_price_per_min" field was cleared in this mutation. +func (m *GroupMutation) AudioRealtimePricePerMinCleared() bool { + _, ok := m.clearedFields[group.FieldAudioRealtimePricePerMin] + return ok +} + +// ResetAudioRealtimePricePerMin resets all changes to the "audio_realtime_price_per_min" field. +func (m *GroupMutation) ResetAudioRealtimePricePerMin() { + m.audio_realtime_price_per_min = nil + m.addaudio_realtime_price_per_min = nil + delete(m.clearedFields, group.FieldAudioRealtimePricePerMin) +} + +// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field. +func (m *GroupMutation) SetAudioTtsPricePerMillionChars(f float64) { + m.audio_tts_price_per_million_chars = &f + m.addaudio_tts_price_per_million_chars = nil +} + +// AudioTtsPricePerMillionChars returns the value of the "audio_tts_price_per_million_chars" field in the mutation. +func (m *GroupMutation) AudioTtsPricePerMillionChars() (r float64, exists bool) { + v := m.audio_tts_price_per_million_chars + if v == nil { + return + } + return *v, true +} + +// OldAudioTtsPricePerMillionChars returns the old "audio_tts_price_per_million_chars" 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) OldAudioTtsPricePerMillionChars(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAudioTtsPricePerMillionChars is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAudioTtsPricePerMillionChars requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAudioTtsPricePerMillionChars: %w", err) + } + return oldValue.AudioTtsPricePerMillionChars, nil +} + +// AddAudioTtsPricePerMillionChars adds f to the "audio_tts_price_per_million_chars" field. +func (m *GroupMutation) AddAudioTtsPricePerMillionChars(f float64) { + if m.addaudio_tts_price_per_million_chars != nil { + *m.addaudio_tts_price_per_million_chars += f + } else { + m.addaudio_tts_price_per_million_chars = &f + } +} + +// AddedAudioTtsPricePerMillionChars returns the value that was added to the "audio_tts_price_per_million_chars" field in this mutation. +func (m *GroupMutation) AddedAudioTtsPricePerMillionChars() (r float64, exists bool) { + v := m.addaudio_tts_price_per_million_chars + if v == nil { + return + } + return *v, true +} + +// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field. +func (m *GroupMutation) ClearAudioTtsPricePerMillionChars() { + m.audio_tts_price_per_million_chars = nil + m.addaudio_tts_price_per_million_chars = nil + m.clearedFields[group.FieldAudioTtsPricePerMillionChars] = struct{}{} +} + +// AudioTtsPricePerMillionCharsCleared returns if the "audio_tts_price_per_million_chars" field was cleared in this mutation. +func (m *GroupMutation) AudioTtsPricePerMillionCharsCleared() bool { + _, ok := m.clearedFields[group.FieldAudioTtsPricePerMillionChars] + return ok +} + +// ResetAudioTtsPricePerMillionChars resets all changes to the "audio_tts_price_per_million_chars" field. +func (m *GroupMutation) ResetAudioTtsPricePerMillionChars() { + m.audio_tts_price_per_million_chars = nil + m.addaudio_tts_price_per_million_chars = nil + delete(m.clearedFields, group.FieldAudioTtsPricePerMillionChars) +} + +// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field. +func (m *GroupMutation) SetAudioSttPricePerHour(f float64) { + m.audio_stt_price_per_hour = &f + m.addaudio_stt_price_per_hour = nil +} + +// AudioSttPricePerHour returns the value of the "audio_stt_price_per_hour" field in the mutation. +func (m *GroupMutation) AudioSttPricePerHour() (r float64, exists bool) { + v := m.audio_stt_price_per_hour + if v == nil { + return + } + return *v, true +} + +// OldAudioSttPricePerHour returns the old "audio_stt_price_per_hour" 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) OldAudioSttPricePerHour(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAudioSttPricePerHour is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAudioSttPricePerHour requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAudioSttPricePerHour: %w", err) + } + return oldValue.AudioSttPricePerHour, nil +} + +// AddAudioSttPricePerHour adds f to the "audio_stt_price_per_hour" field. +func (m *GroupMutation) AddAudioSttPricePerHour(f float64) { + if m.addaudio_stt_price_per_hour != nil { + *m.addaudio_stt_price_per_hour += f + } else { + m.addaudio_stt_price_per_hour = &f + } +} + +// AddedAudioSttPricePerHour returns the value that was added to the "audio_stt_price_per_hour" field in this mutation. +func (m *GroupMutation) AddedAudioSttPricePerHour() (r float64, exists bool) { + v := m.addaudio_stt_price_per_hour + if v == nil { + return + } + return *v, true +} + +// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field. +func (m *GroupMutation) ClearAudioSttPricePerHour() { + m.audio_stt_price_per_hour = nil + m.addaudio_stt_price_per_hour = nil + m.clearedFields[group.FieldAudioSttPricePerHour] = struct{}{} +} + +// AudioSttPricePerHourCleared returns if the "audio_stt_price_per_hour" field was cleared in this mutation. +func (m *GroupMutation) AudioSttPricePerHourCleared() bool { + _, ok := m.clearedFields[group.FieldAudioSttPricePerHour] + return ok +} + +// ResetAudioSttPricePerHour resets all changes to the "audio_stt_price_per_hour" field. +func (m *GroupMutation) ResetAudioSttPricePerHour() { + m.audio_stt_price_per_hour = nil + m.addaudio_stt_price_per_hour = nil + delete(m.clearedFields, group.FieldAudioSttPricePerHour) +} + // SetClaudeCodeOnly sets the "claude_code_only" field. func (m *GroupMutation) SetClaudeCodeOnly(b bool) { m.claude_code_only = &b @@ -24586,6 +24929,154 @@ func (m *GroupMutation) ResetReasoningEffortMappings() { m.appendreasoning_effort_mappings = nil } +// SetProfitControlEnabled sets the "profit_control_enabled" field. +func (m *GroupMutation) SetProfitControlEnabled(b bool) { + m.profit_control_enabled = &b +} + +// ProfitControlEnabled returns the value of the "profit_control_enabled" field in the mutation. +func (m *GroupMutation) ProfitControlEnabled() (r bool, exists bool) { + v := m.profit_control_enabled + if v == nil { + return + } + return *v, true +} + +// OldProfitControlEnabled returns the old "profit_control_enabled" 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) OldProfitControlEnabled(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProfitControlEnabled is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProfitControlEnabled requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProfitControlEnabled: %w", err) + } + return oldValue.ProfitControlEnabled, nil +} + +// ResetProfitControlEnabled resets all changes to the "profit_control_enabled" field. +func (m *GroupMutation) ResetProfitControlEnabled() { + m.profit_control_enabled = nil +} + +// SetProfitMinMargin sets the "profit_min_margin" field. +func (m *GroupMutation) SetProfitMinMargin(f float64) { + m.profit_min_margin = &f + m.addprofit_min_margin = nil +} + +// ProfitMinMargin returns the value of the "profit_min_margin" field in the mutation. +func (m *GroupMutation) ProfitMinMargin() (r float64, exists bool) { + v := m.profit_min_margin + if v == nil { + return + } + return *v, true +} + +// OldProfitMinMargin returns the old "profit_min_margin" 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) OldProfitMinMargin(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProfitMinMargin is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProfitMinMargin requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProfitMinMargin: %w", err) + } + return oldValue.ProfitMinMargin, nil +} + +// AddProfitMinMargin adds f to the "profit_min_margin" field. +func (m *GroupMutation) AddProfitMinMargin(f float64) { + if m.addprofit_min_margin != nil { + *m.addprofit_min_margin += f + } else { + m.addprofit_min_margin = &f + } +} + +// AddedProfitMinMargin returns the value that was added to the "profit_min_margin" field in this mutation. +func (m *GroupMutation) AddedProfitMinMargin() (r float64, exists bool) { + v := m.addprofit_min_margin + if v == nil { + return + } + return *v, true +} + +// ResetProfitMinMargin resets all changes to the "profit_min_margin" field. +func (m *GroupMutation) ResetProfitMinMargin() { + m.profit_min_margin = nil + m.addprofit_min_margin = nil +} + +// SetProfitSafetyBuffer sets the "profit_safety_buffer" field. +func (m *GroupMutation) SetProfitSafetyBuffer(f float64) { + m.profit_safety_buffer = &f + m.addprofit_safety_buffer = nil +} + +// ProfitSafetyBuffer returns the value of the "profit_safety_buffer" field in the mutation. +func (m *GroupMutation) ProfitSafetyBuffer() (r float64, exists bool) { + v := m.profit_safety_buffer + if v == nil { + return + } + return *v, true +} + +// OldProfitSafetyBuffer returns the old "profit_safety_buffer" 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) OldProfitSafetyBuffer(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProfitSafetyBuffer is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProfitSafetyBuffer requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProfitSafetyBuffer: %w", err) + } + return oldValue.ProfitSafetyBuffer, nil +} + +// AddProfitSafetyBuffer adds f to the "profit_safety_buffer" field. +func (m *GroupMutation) AddProfitSafetyBuffer(f float64) { + if m.addprofit_safety_buffer != nil { + *m.addprofit_safety_buffer += f + } else { + m.addprofit_safety_buffer = &f + } +} + +// AddedProfitSafetyBuffer returns the value that was added to the "profit_safety_buffer" field in this mutation. +func (m *GroupMutation) AddedProfitSafetyBuffer() (r float64, exists bool) { + v := m.addprofit_safety_buffer + if v == nil { + return + } + return *v, true +} + +// ResetProfitSafetyBuffer resets all changes to the "profit_safety_buffer" field. +func (m *GroupMutation) ResetProfitSafetyBuffer() { + m.profit_safety_buffer = nil + m.addprofit_safety_buffer = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -24944,7 +25435,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 52) + fields := make([]string, 0, 60) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -25044,9 +25535,24 @@ func (m *GroupMutation) Fields() []string { if m.video_price_1080p != nil { fields = append(fields, group.FieldVideoPrice1080p) } + if m.video_model_prices != nil { + fields = append(fields, group.FieldVideoModelPrices) + } if m.web_search_price_per_call != nil { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.search_price_per_1k != nil { + fields = append(fields, group.FieldSearchPricePer1k) + } + if m.audio_realtime_price_per_min != nil { + fields = append(fields, group.FieldAudioRealtimePricePerMin) + } + if m.audio_tts_price_per_million_chars != nil { + fields = append(fields, group.FieldAudioTtsPricePerMillionChars) + } + if m.audio_stt_price_per_hour != nil { + fields = append(fields, group.FieldAudioSttPricePerHour) + } if m.claude_code_only != nil { fields = append(fields, group.FieldClaudeCodeOnly) } @@ -25101,6 +25607,15 @@ func (m *GroupMutation) Fields() []string { if m.reasoning_effort_mappings != nil { fields = append(fields, group.FieldReasoningEffortMappings) } + if m.profit_control_enabled != nil { + fields = append(fields, group.FieldProfitControlEnabled) + } + if m.profit_min_margin != nil { + fields = append(fields, group.FieldProfitMinMargin) + } + if m.profit_safety_buffer != nil { + fields = append(fields, group.FieldProfitSafetyBuffer) + } return fields } @@ -25175,8 +25690,18 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.VideoPrice720p() case group.FieldVideoPrice1080p: return m.VideoPrice1080p() + case group.FieldVideoModelPrices: + return m.VideoModelPrices() case group.FieldWebSearchPricePerCall: return m.WebSearchPricePerCall() + case group.FieldSearchPricePer1k: + return m.SearchPricePer1k() + case group.FieldAudioRealtimePricePerMin: + return m.AudioRealtimePricePerMin() + case group.FieldAudioTtsPricePerMillionChars: + return m.AudioTtsPricePerMillionChars() + case group.FieldAudioSttPricePerHour: + return m.AudioSttPricePerHour() case group.FieldClaudeCodeOnly: return m.ClaudeCodeOnly() case group.FieldFallbackGroupID: @@ -25213,6 +25738,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.MaxReasoningEffort() case group.FieldReasoningEffortMappings: return m.ReasoningEffortMappings() + case group.FieldProfitControlEnabled: + return m.ProfitControlEnabled() + case group.FieldProfitMinMargin: + return m.ProfitMinMargin() + case group.FieldProfitSafetyBuffer: + return m.ProfitSafetyBuffer() } return nil, false } @@ -25288,8 +25819,18 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldVideoPrice720p(ctx) case group.FieldVideoPrice1080p: return m.OldVideoPrice1080p(ctx) + case group.FieldVideoModelPrices: + return m.OldVideoModelPrices(ctx) case group.FieldWebSearchPricePerCall: return m.OldWebSearchPricePerCall(ctx) + case group.FieldSearchPricePer1k: + return m.OldSearchPricePer1k(ctx) + case group.FieldAudioRealtimePricePerMin: + return m.OldAudioRealtimePricePerMin(ctx) + case group.FieldAudioTtsPricePerMillionChars: + return m.OldAudioTtsPricePerMillionChars(ctx) + case group.FieldAudioSttPricePerHour: + return m.OldAudioSttPricePerHour(ctx) case group.FieldClaudeCodeOnly: return m.OldClaudeCodeOnly(ctx) case group.FieldFallbackGroupID: @@ -25326,6 +25867,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldMaxReasoningEffort(ctx) case group.FieldReasoningEffortMappings: return m.OldReasoningEffortMappings(ctx) + case group.FieldProfitControlEnabled: + return m.OldProfitControlEnabled(ctx) + case group.FieldProfitMinMargin: + return m.OldProfitMinMargin(ctx) + case group.FieldProfitSafetyBuffer: + return m.OldProfitSafetyBuffer(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -25566,6 +26113,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetVideoPrice1080p(v) return nil + case group.FieldVideoModelPrices: + v, ok := value.(map[string]map[string]float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVideoModelPrices(v) + return nil case group.FieldWebSearchPricePerCall: v, ok := value.(float64) if !ok { @@ -25573,6 +26127,34 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetWebSearchPricePerCall(v) return nil + case group.FieldSearchPricePer1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSearchPricePer1k(v) + return nil + case group.FieldAudioRealtimePricePerMin: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAudioRealtimePricePerMin(v) + return nil + case group.FieldAudioTtsPricePerMillionChars: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAudioTtsPricePerMillionChars(v) + return nil + case group.FieldAudioSttPricePerHour: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAudioSttPricePerHour(v) + return nil case group.FieldClaudeCodeOnly: v, ok := value.(bool) if !ok { @@ -25699,6 +26281,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetReasoningEffortMappings(v) return nil + case group.FieldProfitControlEnabled: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProfitControlEnabled(v) + return nil + case group.FieldProfitMinMargin: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProfitMinMargin(v) + return nil + case group.FieldProfitSafetyBuffer: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProfitSafetyBuffer(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -25758,6 +26361,18 @@ func (m *GroupMutation) AddedFields() []string { if m.addweb_search_price_per_call != nil { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.addsearch_price_per_1k != nil { + fields = append(fields, group.FieldSearchPricePer1k) + } + if m.addaudio_realtime_price_per_min != nil { + fields = append(fields, group.FieldAudioRealtimePricePerMin) + } + if m.addaudio_tts_price_per_million_chars != nil { + fields = append(fields, group.FieldAudioTtsPricePerMillionChars) + } + if m.addaudio_stt_price_per_hour != nil { + fields = append(fields, group.FieldAudioSttPricePerHour) + } if m.addfallback_group_id != nil { fields = append(fields, group.FieldFallbackGroupID) } @@ -25770,6 +26385,12 @@ func (m *GroupMutation) AddedFields() []string { if m.addrpm_limit != nil { fields = append(fields, group.FieldRpmLimit) } + if m.addprofit_min_margin != nil { + fields = append(fields, group.FieldProfitMinMargin) + } + if m.addprofit_safety_buffer != nil { + fields = append(fields, group.FieldProfitSafetyBuffer) + } return fields } @@ -25812,6 +26433,14 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedVideoPrice1080p() case group.FieldWebSearchPricePerCall: return m.AddedWebSearchPricePerCall() + case group.FieldSearchPricePer1k: + return m.AddedSearchPricePer1k() + case group.FieldAudioRealtimePricePerMin: + return m.AddedAudioRealtimePricePerMin() + case group.FieldAudioTtsPricePerMillionChars: + return m.AddedAudioTtsPricePerMillionChars() + case group.FieldAudioSttPricePerHour: + return m.AddedAudioSttPricePerHour() case group.FieldFallbackGroupID: return m.AddedFallbackGroupID() case group.FieldFallbackGroupIDOnInvalidRequest: @@ -25820,6 +26449,10 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedSortOrder() case group.FieldRpmLimit: return m.AddedRpmLimit() + case group.FieldProfitMinMargin: + return m.AddedProfitMinMargin() + case group.FieldProfitSafetyBuffer: + return m.AddedProfitSafetyBuffer() } return nil, false } @@ -25948,6 +26581,34 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddWebSearchPricePerCall(v) return nil + case group.FieldSearchPricePer1k: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddSearchPricePer1k(v) + return nil + case group.FieldAudioRealtimePricePerMin: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAudioRealtimePricePerMin(v) + return nil + case group.FieldAudioTtsPricePerMillionChars: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAudioTtsPricePerMillionChars(v) + return nil + case group.FieldAudioSttPricePerHour: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddAudioSttPricePerHour(v) + return nil case group.FieldFallbackGroupID: v, ok := value.(int64) if !ok { @@ -25976,6 +26637,20 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddRpmLimit(v) return nil + case group.FieldProfitMinMargin: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddProfitMinMargin(v) + return nil + case group.FieldProfitSafetyBuffer: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddProfitSafetyBuffer(v) + return nil } return fmt.Errorf("unknown Group numeric field %s", name) } @@ -26020,9 +26695,24 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldVideoPrice1080p) { fields = append(fields, group.FieldVideoPrice1080p) } + if m.FieldCleared(group.FieldVideoModelPrices) { + fields = append(fields, group.FieldVideoModelPrices) + } if m.FieldCleared(group.FieldWebSearchPricePerCall) { fields = append(fields, group.FieldWebSearchPricePerCall) } + if m.FieldCleared(group.FieldSearchPricePer1k) { + fields = append(fields, group.FieldSearchPricePer1k) + } + if m.FieldCleared(group.FieldAudioRealtimePricePerMin) { + fields = append(fields, group.FieldAudioRealtimePricePerMin) + } + if m.FieldCleared(group.FieldAudioTtsPricePerMillionChars) { + fields = append(fields, group.FieldAudioTtsPricePerMillionChars) + } + if m.FieldCleared(group.FieldAudioSttPricePerHour) { + fields = append(fields, group.FieldAudioSttPricePerHour) + } if m.FieldCleared(group.FieldFallbackGroupID) { fields = append(fields, group.FieldFallbackGroupID) } @@ -26082,9 +26772,24 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldVideoPrice1080p: m.ClearVideoPrice1080p() return nil + case group.FieldVideoModelPrices: + m.ClearVideoModelPrices() + return nil case group.FieldWebSearchPricePerCall: m.ClearWebSearchPricePerCall() return nil + case group.FieldSearchPricePer1k: + m.ClearSearchPricePer1k() + return nil + case group.FieldAudioRealtimePricePerMin: + m.ClearAudioRealtimePricePerMin() + return nil + case group.FieldAudioTtsPricePerMillionChars: + m.ClearAudioTtsPricePerMillionChars() + return nil + case group.FieldAudioSttPricePerHour: + m.ClearAudioSttPricePerHour() + return nil case group.FieldFallbackGroupID: m.ClearFallbackGroupID() return nil @@ -26201,9 +26906,24 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldVideoPrice1080p: m.ResetVideoPrice1080p() return nil + case group.FieldVideoModelPrices: + m.ResetVideoModelPrices() + return nil case group.FieldWebSearchPricePerCall: m.ResetWebSearchPricePerCall() return nil + case group.FieldSearchPricePer1k: + m.ResetSearchPricePer1k() + return nil + case group.FieldAudioRealtimePricePerMin: + m.ResetAudioRealtimePricePerMin() + return nil + case group.FieldAudioTtsPricePerMillionChars: + m.ResetAudioTtsPricePerMillionChars() + return nil + case group.FieldAudioSttPricePerHour: + m.ResetAudioSttPricePerHour() + return nil case group.FieldClaudeCodeOnly: m.ResetClaudeCodeOnly() return nil @@ -26258,6 +26978,15 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldReasoningEffortMappings: m.ResetReasoningEffortMappings() return nil + case group.FieldProfitControlEnabled: + m.ResetProfitControlEnabled() + return nil + case group.FieldProfitMinMargin: + m.ResetProfitMinMargin() + return nil + case group.FieldProfitSafetyBuffer: + m.ResetProfitSafetyBuffer() + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -43128,6 +43857,8 @@ type UsageLogMutation struct { model *string requested_model *string upstream_model *string + upstream_response_model *string + upstream_model_mismatch *bool channel_id *int64 addchannel_id *int64 model_mapping_chain *string @@ -43577,6 +44308,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() { delete(m.clearedFields, usagelog.FieldUpstreamModel) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (m *UsageLogMutation) SetUpstreamResponseModel(s string) { + m.upstream_response_model = &s +} + +// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation. +func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) { + v := m.upstream_response_model + if v == nil { + return + } + return *v, true +} + +// OldUpstreamResponseModel returns the old "upstream_response_model" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err) + } + return oldValue.UpstreamResponseModel, nil +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (m *UsageLogMutation) ClearUpstreamResponseModel() { + m.upstream_response_model = nil + m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{} +} + +// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation. +func (m *UsageLogMutation) UpstreamResponseModelCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel] + return ok +} + +// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field. +func (m *UsageLogMutation) ResetUpstreamResponseModel() { + m.upstream_response_model = nil + delete(m.clearedFields, usagelog.FieldUpstreamResponseModel) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) { + m.upstream_model_mismatch = &b +} + +// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation. +func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) { + v := m.upstream_model_mismatch + if v == nil { + return + } + return *v, true +} + +// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err) + } + return oldValue.UpstreamModelMismatch, nil +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (m *UsageLogMutation) ClearUpstreamModelMismatch() { + m.upstream_model_mismatch = nil + m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{} +} + +// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation. +func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool { + _, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch] + return ok +} + +// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field. +func (m *UsageLogMutation) ResetUpstreamModelMismatch() { + m.upstream_model_mismatch = nil + delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch) +} + // SetChannelID sets the "channel_id" field. func (m *UsageLogMutation) SetChannelID(i int64) { m.channel_id = &i @@ -45773,7 +46602,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 45) + fields := make([]string, 0, 47) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -45795,6 +46624,12 @@ func (m *UsageLogMutation) Fields() []string { if m.upstream_model != nil { fields = append(fields, usagelog.FieldUpstreamModel) } + if m.upstream_response_model != nil { + fields = append(fields, usagelog.FieldUpstreamResponseModel) + } + if m.upstream_model_mismatch != nil { + fields = append(fields, usagelog.FieldUpstreamModelMismatch) + } if m.channel_id != nil { fields = append(fields, usagelog.FieldChannelID) } @@ -45931,6 +46766,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.RequestedModel() case usagelog.FieldUpstreamModel: return m.UpstreamModel() + case usagelog.FieldUpstreamResponseModel: + return m.UpstreamResponseModel() + case usagelog.FieldUpstreamModelMismatch: + return m.UpstreamModelMismatch() case usagelog.FieldChannelID: return m.ChannelID() case usagelog.FieldModelMappingChain: @@ -46030,6 +46869,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldRequestedModel(ctx) case usagelog.FieldUpstreamModel: return m.OldUpstreamModel(ctx) + case usagelog.FieldUpstreamResponseModel: + return m.OldUpstreamResponseModel(ctx) + case usagelog.FieldUpstreamModelMismatch: + return m.OldUpstreamModelMismatch(ctx) case usagelog.FieldChannelID: return m.OldChannelID(ctx) case usagelog.FieldModelMappingChain: @@ -46164,6 +47007,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetUpstreamModel(v) return nil + case usagelog.FieldUpstreamResponseModel: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpstreamResponseModel(v) + return nil + case usagelog.FieldUpstreamModelMismatch: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpstreamModelMismatch(v) + return nil case usagelog.FieldChannelID: v, ok := value.(int64) if !ok { @@ -46721,6 +47578,12 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldUpstreamModel) { fields = append(fields, usagelog.FieldUpstreamModel) } + if m.FieldCleared(usagelog.FieldUpstreamResponseModel) { + fields = append(fields, usagelog.FieldUpstreamResponseModel) + } + if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) { + fields = append(fields, usagelog.FieldUpstreamModelMismatch) + } if m.FieldCleared(usagelog.FieldChannelID) { fields = append(fields, usagelog.FieldChannelID) } @@ -46795,6 +47658,12 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldUpstreamModel: m.ClearUpstreamModel() return nil + case usagelog.FieldUpstreamResponseModel: + m.ClearUpstreamResponseModel() + return nil + case usagelog.FieldUpstreamModelMismatch: + m.ClearUpstreamModelMismatch() + return nil case usagelog.FieldChannelID: m.ClearChannelID() return nil @@ -46878,6 +47747,12 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldUpstreamModel: m.ResetUpstreamModel() return nil + case usagelog.FieldUpstreamResponseModel: + m.ResetUpstreamResponseModel() + return nil + case usagelog.FieldUpstreamModelMismatch: + m.ResetUpstreamModelMismatch() + return nil case usagelog.FieldChannelID: m.ResetChannelID() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 58d1a5f35e..8042a835a9 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1117,70 +1117,98 @@ func init() { groupDescVideoRateMultiplier := groupFields[26].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) + // groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field. + groupDescSearchPricePer1k := groupFields[32].Descriptor() + // group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. + group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error) + // groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field. + groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor() + // group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. + group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error) + // groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field. + groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor() + // group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. + group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error) + // groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field. + groupDescAudioSttPricePerHour := groupFields[35].Descriptor() + // group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. + group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[31].Descriptor() + groupDescClaudeCodeOnly := groupFields[36].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[35].Descriptor() + groupDescModelRoutingEnabled := groupFields[40].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[36].Descriptor() + groupDescMcpXMLInject := groupFields[41].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[37].Descriptor() + groupDescSupportedModelScopes := groupFields[42].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[38].Descriptor() + groupDescSortOrder := groupFields[43].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[39].Descriptor() + groupDescAllowMessagesDispatch := groupFields[44].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[40].Descriptor() + groupDescAllowLive := groupFields[45].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[41].Descriptor() + groupDescRequireOauthOnly := groupFields[46].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[42].Descriptor() + groupDescRequirePrivacySet := groupFields[47].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[43].Descriptor() + groupDescDefaultMappedModel := groupFields[48].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[44].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[49].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[45].Descriptor() + groupDescModelsListConfig := groupFields[50].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[46].Descriptor() + groupDescRpmLimit := groupFields[51].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[47].Descriptor() + groupDescMaxReasoningEffort := groupFields[52].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[48].Descriptor() + groupDescReasoningEffortMappings := groupFields[53].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) + // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. + groupDescProfitControlEnabled := groupFields[54].Descriptor() + // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. + group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) + // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. + groupDescProfitMinMargin := groupFields[55].Descriptor() + // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. + group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) + // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. + groupDescProfitSafetyBuffer := groupFields[56].Descriptor() + // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. + group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields() _ = idempotencyrecordMixinFields0 @@ -1970,124 +1998,128 @@ func init() { usagelogDescUpstreamModel := usagelogFields[6].Descriptor() // usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error) + // usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field. + usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor() + // usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save. + usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error) // usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field. - usagelogDescModelMappingChain := usagelogFields[8].Descriptor() + usagelogDescModelMappingChain := usagelogFields[10].Descriptor() // usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save. usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error) // usagelogDescBillingTier is the schema descriptor for billing_tier field. - usagelogDescBillingTier := usagelogFields[9].Descriptor() + usagelogDescBillingTier := usagelogFields[11].Descriptor() // usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save. usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error) // usagelogDescBillingMode is the schema descriptor for billing_mode field. - usagelogDescBillingMode := usagelogFields[10].Descriptor() + usagelogDescBillingMode := usagelogFields[12].Descriptor() // usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save. usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error) // usagelogDescInputTokens is the schema descriptor for input_tokens field. - usagelogDescInputTokens := usagelogFields[13].Descriptor() + usagelogDescInputTokens := usagelogFields[15].Descriptor() // usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field. usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int) // usagelogDescOutputTokens is the schema descriptor for output_tokens field. - usagelogDescOutputTokens := usagelogFields[14].Descriptor() + usagelogDescOutputTokens := usagelogFields[16].Descriptor() // usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field. usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int) // usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field. - usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor() + usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor() // usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field. usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int) // usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field. - usagelogDescCacheReadTokens := usagelogFields[16].Descriptor() + usagelogDescCacheReadTokens := usagelogFields[18].Descriptor() // usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field. usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int) // usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field. - usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor() + usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor() // usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field. usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int) // usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field. - usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor() + usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor() // usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field. usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int) // usagelogDescInputCost is the schema descriptor for input_cost field. - usagelogDescInputCost := usagelogFields[19].Descriptor() + usagelogDescInputCost := usagelogFields[21].Descriptor() // usagelog.DefaultInputCost holds the default value on creation for the input_cost field. usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64) // usagelogDescOutputCost is the schema descriptor for output_cost field. - usagelogDescOutputCost := usagelogFields[20].Descriptor() + usagelogDescOutputCost := usagelogFields[22].Descriptor() // usagelog.DefaultOutputCost holds the default value on creation for the output_cost field. usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64) // usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field. - usagelogDescCacheCreationCost := usagelogFields[21].Descriptor() + usagelogDescCacheCreationCost := usagelogFields[23].Descriptor() // usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field. usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64) // usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field. - usagelogDescCacheReadCost := usagelogFields[22].Descriptor() + usagelogDescCacheReadCost := usagelogFields[24].Descriptor() // usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field. usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64) // usagelogDescTotalCost is the schema descriptor for total_cost field. - usagelogDescTotalCost := usagelogFields[23].Descriptor() + usagelogDescTotalCost := usagelogFields[25].Descriptor() // usagelog.DefaultTotalCost holds the default value on creation for the total_cost field. usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64) // usagelogDescActualCost is the schema descriptor for actual_cost field. - usagelogDescActualCost := usagelogFields[24].Descriptor() + usagelogDescActualCost := usagelogFields[26].Descriptor() // usagelog.DefaultActualCost holds the default value on creation for the actual_cost field. usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64) // usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field. - usagelogDescRateMultiplier := usagelogFields[25].Descriptor() + usagelogDescRateMultiplier := usagelogFields[27].Descriptor() // usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field. usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64) // usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field. - usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor() + usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor() // usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field. usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool) // usagelogDescBillingType is the schema descriptor for billing_type field. - usagelogDescBillingType := usagelogFields[28].Descriptor() + usagelogDescBillingType := usagelogFields[30].Descriptor() // usagelog.DefaultBillingType holds the default value on creation for the billing_type field. usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8) // usagelogDescStream is the schema descriptor for stream field. - usagelogDescStream := usagelogFields[29].Descriptor() + usagelogDescStream := usagelogFields[31].Descriptor() // usagelog.DefaultStream holds the default value on creation for the stream field. usagelog.DefaultStream = usagelogDescStream.Default.(bool) // usagelogDescUserAgent is the schema descriptor for user_agent field. - usagelogDescUserAgent := usagelogFields[32].Descriptor() + usagelogDescUserAgent := usagelogFields[34].Descriptor() // usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error) // usagelogDescIPAddress is the schema descriptor for ip_address field. - usagelogDescIPAddress := usagelogFields[33].Descriptor() + usagelogDescIPAddress := usagelogFields[35].Descriptor() // usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save. usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error) // usagelogDescImageCount is the schema descriptor for image_count field. - usagelogDescImageCount := usagelogFields[34].Descriptor() + usagelogDescImageCount := usagelogFields[36].Descriptor() // usagelog.DefaultImageCount holds the default value on creation for the image_count field. usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int) // usagelogDescImageSize is the schema descriptor for image_size field. - usagelogDescImageSize := usagelogFields[35].Descriptor() + usagelogDescImageSize := usagelogFields[37].Descriptor() // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) // usagelogDescImageInputSize is the schema descriptor for image_input_size field. - usagelogDescImageInputSize := usagelogFields[36].Descriptor() + usagelogDescImageInputSize := usagelogFields[38].Descriptor() // usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save. usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error) // usagelogDescImageOutputSize is the schema descriptor for image_output_size field. - usagelogDescImageOutputSize := usagelogFields[37].Descriptor() + usagelogDescImageOutputSize := usagelogFields[39].Descriptor() // usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save. usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error) // usagelogDescImageSizeSource is the schema descriptor for image_size_source field. - usagelogDescImageSizeSource := usagelogFields[38].Descriptor() + usagelogDescImageSizeSource := usagelogFields[40].Descriptor() // usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save. usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error) // usagelogDescVideoCount is the schema descriptor for video_count field. - usagelogDescVideoCount := usagelogFields[40].Descriptor() + usagelogDescVideoCount := usagelogFields[42].Descriptor() // usagelog.DefaultVideoCount holds the default value on creation for the video_count field. usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int) // usagelogDescVideoResolution is the schema descriptor for video_resolution field. - usagelogDescVideoResolution := usagelogFields[41].Descriptor() + usagelogDescVideoResolution := usagelogFields[43].Descriptor() // usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save. usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error) // usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field. - usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor() + usagelogDescCacheTTLOverridden := usagelogFields[45].Descriptor() // usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field. usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[44].Descriptor() + usagelogDescCreatedAt := usagelogFields[46].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 4b6d056ef6..85f86891b8 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -148,12 +148,44 @@ func (Group) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}), + field.JSON("video_model_prices", map[string]map[string]float64{}). + Optional(). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("按模型族和分辨率覆盖视频每秒价格"), field.Float("web_search_price_per_call"). Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"), + // 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。 + field.Float("search_price_per_1k"). + Optional(). + Nillable(). + Min(0). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("搜索工具价格 per 1000 calls(web_search 等)"), + + // Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。 + field.Float("audio_realtime_price_per_min"). + Optional(). + Nillable(). + Min(0). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("Voice realtime 每分钟价格(USD)"), + field.Float("audio_tts_price_per_million_chars"). + Optional(). + Nillable(). + Min(0). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("TTS 每百万字符价格(USD)"), + field.Float("audio_stt_price_per_hour"). + Optional(). + Nillable(). + Min(0). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("STT 每小时价格(USD)"), + // Claude Code 客户端限制 (added by migration 029) field.Bool("claude_code_only"). Default(false). @@ -234,6 +266,20 @@ func (Group) Fields() []ent.Field { Default([]domain.ReasoningEffortMapping{}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"), + + // 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity + // 的 token 分组可启用,composite 分组不能直接启用。 + field.Bool("profit_control_enabled"). + Default(false). + Comment("是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池"), + field.Float("profit_min_margin"). + SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). + Default(0). + Comment("最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)"), + field.Float("profit_safety_buffer"). + SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). + Default(0). + Comment("安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0"), } } diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index 6d8c2d4191..f13d07170c 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field { MaxLen(100). Optional(). Nillable(), + // UpstreamResponseModel stores the model name declared by the upstream + // response before any protocol conversion or client-facing rewrite. + field.String("upstream_response_model"). + MaxLen(200). + Optional(). + Nillable(), + // UpstreamModelMismatch is tri-state: NULL means the upstream response did + // not declare a model (or predates this field); false/true means observed. + field.Bool("upstream_model_mismatch"). + Optional(). + Nillable(), field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"), field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"), field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"), diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index b13e29b2f7..1bd424f8d5 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -37,6 +37,10 @@ type UsageLog struct { RequestedModel *string `json:"requested_model,omitempty"` // UpstreamModel holds the value of the "upstream_model" field. UpstreamModel *string `json:"upstream_model,omitempty"` + // UpstreamResponseModel holds the value of the "upstream_response_model" field. + UpstreamResponseModel *string `json:"upstream_response_model,omitempty"` + // UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field. + UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"` // 渠道 ID ChannelID *int64 `json:"channel_id,omitempty"` // 模型映射链 @@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { switch columns[i] { case usagelog.FieldImageSizeBreakdown: values[i] = new([]byte) - case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden: + case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden: values[i] = new(sql.NullBool) case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier: values[i] = new(sql.NullFloat64) case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.UpstreamModel = new(string) *_m.UpstreamModel = value.String } + case usagelog.FieldUpstreamResponseModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i]) + } else if value.Valid { + _m.UpstreamResponseModel = new(string) + *_m.UpstreamResponseModel = value.String + } + case usagelog.FieldUpstreamModelMismatch: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i]) + } else if value.Valid { + _m.UpstreamModelMismatch = new(bool) + *_m.UpstreamModelMismatch = value.Bool + } case usagelog.FieldChannelID: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field channel_id", values[i]) @@ -606,6 +624,16 @@ func (_m *UsageLog) String() string { builder.WriteString(*v) } builder.WriteString(", ") + if v := _m.UpstreamResponseModel; v != nil { + builder.WriteString("upstream_response_model=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.UpstreamModelMismatch; v != nil { + builder.WriteString("upstream_model_mismatch=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") if v := _m.ChannelID; v != nil { builder.WriteString("channel_id=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index a87d937195..d3f8dfa1b3 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -28,6 +28,10 @@ const ( FieldRequestedModel = "requested_model" // FieldUpstreamModel holds the string denoting the upstream_model field in the database. FieldUpstreamModel = "upstream_model" + // FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database. + FieldUpstreamResponseModel = "upstream_response_model" + // FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database. + FieldUpstreamModelMismatch = "upstream_model_mismatch" // FieldChannelID holds the string denoting the channel_id field in the database. FieldChannelID = "channel_id" // FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database. @@ -163,6 +167,8 @@ var Columns = []string{ FieldModel, FieldRequestedModel, FieldUpstreamModel, + FieldUpstreamResponseModel, + FieldUpstreamModelMismatch, FieldChannelID, FieldModelMappingChain, FieldBillingTier, @@ -222,6 +228,8 @@ var ( RequestedModelValidator func(string) error // UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save. UpstreamModelValidator func(string) error + // UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save. + UpstreamResponseModelValidator func(string) error // ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save. ModelMappingChainValidator func(string) error // BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save. @@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc() } +// ByUpstreamResponseModel orders the results by the upstream_response_model field. +func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc() +} + +// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field. +func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc() +} + // ByChannelID orders the results by the channel_id field. func ByChannelID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldChannelID, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index a9462e0d0e..7982d74c5f 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v)) } +// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ. +func UpstreamResponseModel(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ. +func UpstreamModelMismatch(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v)) +} + // ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ. func ChannelID(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v)) @@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v)) } +// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field. +func UpstreamResponseModelEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field. +func UpstreamResponseModelNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field. +func UpstreamResponseModelIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...)) +} + +// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field. +func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...)) +} + +// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field. +func UpstreamResponseModelGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field. +func UpstreamResponseModelGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field. +func UpstreamResponseModelLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field. +func UpstreamResponseModelLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field. +func UpstreamResponseModelContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field. +func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field. +func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field. +func UpstreamResponseModelIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel)) +} + +// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field. +func UpstreamResponseModelNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel)) +} + +// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field. +func UpstreamResponseModelEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v)) +} + +// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field. +func UpstreamResponseModelContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v)) +} + +// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchEQ(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v)) +} + +// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v)) +} + +// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch)) +} + +// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field. +func UpstreamModelMismatchNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch)) +} + // ChannelIDEQ applies the EQ predicate on the "channel_id" field. func ChannelIDEQ(v int64) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index 31cf45328e..8178fa0710 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate { return _c } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate { + _c.mutation.SetUpstreamResponseModel(v) + return _c +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate { + if v != nil { + _c.SetUpstreamResponseModel(*v) + } + return _c +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate { + _c.mutation.SetUpstreamModelMismatch(v) + return _c +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate { + if v != nil { + _c.SetUpstreamModelMismatch(*v) + } + return _c +} + // SetChannelID sets the "channel_id" field. func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate { _c.mutation.SetChannelID(v) @@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _c.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _c.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value) _node.UpstreamModel = &value } + if value, ok := _c.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + _node.UpstreamResponseModel = &value + } + if value, ok := _c.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + _node.UpstreamModelMismatch = &value + } if value, ok := _c.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) _node.ChannelID = &value @@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert { return u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert { + u.Set(usagelog.FieldUpstreamResponseModel, v) + return u +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUpstreamResponseModel) + return u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert { + u.SetNull(usagelog.FieldUpstreamResponseModel) + return u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert { + u.Set(usagelog.FieldUpstreamModelMismatch, v) + return u +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldUpstreamModelMismatch) + return u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert { + u.SetNull(usagelog.FieldUpstreamModelMismatch) + return u +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert { u.Set(usagelog.FieldChannelID, v) @@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne { }) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamResponseModel(v) + }) +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamResponseModel() + }) +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamResponseModel() + }) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModelMismatch(v) + }) +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModelMismatch() + }) +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModelMismatch() + }) +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk { }) } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamResponseModel(v) + }) +} + +// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamResponseModel() + }) +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamResponseModel() + }) +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetUpstreamModelMismatch(v) + }) +} + +// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateUpstreamModelMismatch() + }) +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearUpstreamModelMismatch() + }) +} + // SetChannelID sets the "channel_id" field. func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 2a60d6f44d..6c23ebd355 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate { return _u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate { + _u.mutation.SetUpstreamResponseModel(v) + return _u +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate { + if v != nil { + _u.SetUpstreamResponseModel(*v) + } + return _u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate { + _u.mutation.ClearUpstreamResponseModel() + return _u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate { + _u.mutation.SetUpstreamModelMismatch(v) + return _u +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate { + if v != nil { + _u.SetUpstreamModelMismatch(*v) + } + return _u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate { + _u.mutation.ClearUpstreamModelMismatch() + return _u +} + // SetChannelID sets the "channel_id" field. func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate { _u.mutation.ResetChannelID() @@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _u.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _u.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.UpstreamModelCleared() { _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) } + if value, ok := _u.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + } + if _u.mutation.UpstreamResponseModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString) + } + if value, ok := _u.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + } + if _u.mutation.UpstreamModelMismatchCleared() { + _spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool) + } if value, ok := _u.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) } @@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne { return _u } +// SetUpstreamResponseModel sets the "upstream_response_model" field. +func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne { + _u.mutation.SetUpstreamResponseModel(v) + return _u +} + +// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetUpstreamResponseModel(*v) + } + return _u +} + +// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field. +func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne { + _u.mutation.ClearUpstreamResponseModel() + return _u +} + +// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field. +func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne { + _u.mutation.SetUpstreamModelMismatch(v) + return _u +} + +// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne { + if v != nil { + _u.SetUpstreamModelMismatch(*v) + } + return _u +} + +// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field. +func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne { + _u.mutation.ClearUpstreamModelMismatch() + return _u +} + // SetChannelID sets the "channel_id" field. func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne { _u.mutation.ResetChannelID() @@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)} } } + if v, ok := _u.mutation.UpstreamResponseModel(); ok { + if err := usagelog.UpstreamResponseModelValidator(v); err != nil { + return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)} + } + } if v, ok := _u.mutation.ModelMappingChain(); ok { if err := usagelog.ModelMappingChainValidator(v); err != nil { return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)} @@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.UpstreamModelCleared() { _spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString) } + if value, ok := _u.mutation.UpstreamResponseModel(); ok { + _spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value) + } + if _u.mutation.UpstreamResponseModelCleared() { + _spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString) + } + if value, ok := _u.mutation.UpstreamModelMismatch(); ok { + _spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value) + } + if _u.mutation.UpstreamModelMismatchCleared() { + _spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool) + } if value, ok := _u.mutation.ChannelID(); ok { _spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value) } diff --git a/backend/go.mod b/backend/go.mod index e57756f77a..d6f1c10ed6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,9 @@ go 1.26.5 require ( entgo.io/ent v0.14.5 github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/alibabacloud-go/captcha-20230305 v1.1.3 + github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 + github.com/alibabacloud-go/tea v1.3.13 github.com/alicebob/miniredis/v2 v2.38.0 github.com/alitto/pond/v2 v2.6.2 github.com/andybalholm/brotli v1.2.0 @@ -35,6 +38,8 @@ require ( github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.11.1 github.com/stripe/stripe-go/v85 v85.0.0 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/redis v0.40.0 github.com/tidwall/gjson v1.18.0 @@ -60,6 +65,10 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect + github.com/alibabacloud-go/debug v1.0.1 // indirect + github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect + github.com/aliyun/credentials-go v1.4.5 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect @@ -81,6 +90,7 @@ require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -165,6 +175,7 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 896cfa3be6..cdea1a5b91 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,6 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc= ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= @@ -8,6 +9,7 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -16,10 +18,56 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/captcha-20230305 v1.1.3 h1:0Aobw12m3x28aeDMPjwjXsfF8MuLvRjlQ4Hhoy5hFOY= +github.com/alibabacloud-go/captcha-20230305 v1.1.3/go.mod h1:ydzBIN2OiM7eeQPpAFyBrv1H5TY1MtUP2rQig44C4UQ= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94= +github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg= +github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0= +github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw= github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk= +github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= @@ -77,11 +125,16 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -120,6 +173,9 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +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.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= @@ -163,25 +219,45 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +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/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= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= 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= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 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= @@ -205,8 +281,10 @@ 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/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= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= @@ -215,6 +293,8 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -232,6 +312,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk 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= @@ -259,12 +341,17 @@ github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 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/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= @@ -286,6 +373,7 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -298,6 +386,8 @@ 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= @@ -322,6 +412,9 @@ github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I= github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= @@ -330,16 +423,20 @@ 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= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -353,6 +450,10 @@ github.com/stripe/stripe-go/v85 v85.0.0 h1:HMlFJXW6I/9WvkeSAtj8V7dI5pzeDu4gS1Taq github.com/stripe/stripe-go/v85 v85.0.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 h1:bPz4h9cPAD2psXNdVNHZUM/V13P05rtXoFIFn1IIPoA= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52/go.mod h1:KDlcSxrt2pw9nenvXpw/VHipuokbA0j2iRXV+2gF5j8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 h1:agyo5WB5bclK346U0Y4G40c//eA5qZbtBVGdp3HhuK8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= @@ -372,6 +473,9 @@ github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -386,6 +490,9 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -429,59 +536,189 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-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= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-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= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= +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/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/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= 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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 46d5c60429..0ed2b17213 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'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.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://fonts.googleapis.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:; frame-src https://challenges.cloudflare.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 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 ( @@ -907,6 +907,18 @@ type GatewayConfig struct { // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 ForceCodexCLI bool `mapstructure:"force_codex_cli"` + // DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex + // 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内 + // server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的 + // User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份 + // 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。 + // + // 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的 + // Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。 + DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"` + // DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。 + // 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。 + DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"` // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。 // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。 CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"` @@ -1010,6 +1022,39 @@ type GatewayConfig struct { // UserMessageQueue: 用户消息串行队列配置 // 对 role:"user" 的真实用户消息实施账号级串行化 + RPM 自适应延迟 UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"` + + // Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings. + Grok GatewayGrokConfig `mapstructure:"grok"` +} + +// GatewayGrokConfig holds Grok-specific gateway scheduling knobs. +// +// Free-quota soft gate keys (gateway.grok.*): +// - free_quota_soft_gate_enabled: enable local rolling-window scheduling guard for +// OAuth accounts whose subscription_tier/plan_type is explicitly "free". +// Default true is safe only because free-tier detection is strict (unknown/paid fail open). +// - free_quota_token_limit: nominal rolling-window token allowance. +// - free_quota_soft_gate_percent: stop new scheduling before the nominal limit (1-100). +// - free_quota_window_hours: local usage rolling window length in hours. +// - free_quota_stats_cache_seconds: cache TTL for free-tier usage stats +// (hot path never blocks on DB; misses fail open and refresh in background). +type GatewayGrokConfig struct { + // PasswordAuthEnabled controls the optional password-to-SSO OAuth flow. + // It defaults to false and must be explicitly enabled by the operator. + // When true, POST /admin/grok/oauth/password is functional (not ignored). + PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"` + // FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard + // for explicitly free Grok OAuth accounts only. + FreeQuotaSoftGateEnabled bool `mapstructure:"free_quota_soft_gate_enabled"` + // FreeQuotaTokenLimit is the nominal rolling-window allowance. + FreeQuotaTokenLimit int64 `mapstructure:"free_quota_token_limit"` + // FreeQuotaSoftGatePercent stops new scheduling before the nominal limit. + FreeQuotaSoftGatePercent int `mapstructure:"free_quota_soft_gate_percent"` + // FreeQuotaWindowHours controls the local rolling usage window. + FreeQuotaWindowHours int `mapstructure:"free_quota_window_hours"` + // FreeQuotaStatsCacheSeconds is the soft-gate stats cache TTL. Hot path never + // waits on usage_logs; misses fail open and refresh asynchronously. + FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"` } type GatewayLiveConfig struct { @@ -1035,6 +1080,8 @@ type GatewayOpenAIHTTP2Config struct { // GatewayOpenAIProxyStreamCircuitConfig controls the bounded, in-process // proxy-ID circuit used for incomplete OpenAI Responses SSE streams. type GatewayOpenAIProxyStreamCircuitConfig struct { + // Disabled: 完全关闭代理断流熔断(默认开启)。 + Disabled bool `mapstructure:"disabled"` // FailureThreshold: 统计窗口内多少次断流后隔离代理。 FailureThreshold int `mapstructure:"failure_threshold"` // WindowSeconds: 断流统计窗口(秒)。 @@ -1764,6 +1811,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.Gateway.ForcedCodexInstructionsTemplate = string(content) } + // 兼容旧键 gateway.disable_codex_originator_normalization:语义已被 + // disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口), + // 任一为 true 即关闭强制统一。 + if cfg.Gateway.DisableCodexOriginatorNormalization { + cfg.Gateway.DisableCodexIdentityEnforcement = true + } + // 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。 // 新键未配置(<=0)时回退旧键;新键优先。 if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 { @@ -2215,6 +2269,8 @@ func setDefaults() { viper.SetDefault("gateway.max_account_switches", 10) viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.force_codex_cli", false) + viper.SetDefault("gateway.disable_codex_identity_enforcement", false) + viper.SetDefault("gateway.disable_codex_originator_normalization", false) viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") @@ -2282,9 +2338,19 @@ func setDefaults() { viper.SetDefault("gateway.openai_http2.fallback_error_threshold", 2) viper.SetDefault("gateway.openai_http2.fallback_window_seconds", 60) viper.SetDefault("gateway.openai_http2.fallback_ttl_seconds", 600) + viper.SetDefault("gateway.openai_proxy_stream_circuit.disabled", false) viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2) viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60) viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600) + // Grok free-tier local soft gate (scheduler-only; admin QueryQuota does not use this). + // Enabled by default because free detection requires an explicit free tier marker. + viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true) + viper.SetDefault("gateway.grok.password_auth_enabled", false) + // Free soft-gate nominal limit: 500k tokens / rolling 24h (operator policy). + viper.SetDefault("gateway.grok.free_quota_token_limit", int64(500_000)) + 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) viper.SetDefault("gateway.image_concurrency.enabled", false) viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0) viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject) @@ -3494,6 +3560,20 @@ func (c *Config) Validate() error { if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 { return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds") } + if c.Gateway.Grok.FreeQuotaSoftGateEnabled { + if c.Gateway.Grok.FreeQuotaTokenLimit <= 0 { + return fmt.Errorf("gateway.grok.free_quota_token_limit must be positive") + } + if c.Gateway.Grok.FreeQuotaSoftGatePercent < 1 || c.Gateway.Grok.FreeQuotaSoftGatePercent > 100 { + return fmt.Errorf("gateway.grok.free_quota_soft_gate_percent must be between 1 and 100") + } + if c.Gateway.Grok.FreeQuotaWindowHours <= 0 { + return fmt.Errorf("gateway.grok.free_quota_window_hours must be positive") + } + } + if c.Gateway.Grok.FreeQuotaStatsCacheSeconds < 0 { + return fmt.Errorf("gateway.grok.free_quota_stats_cache_seconds must be non-negative") + } if err := ValidateDingTalkConfig(c.DingTalk); err != nil { return fmt.Errorf("dingtalk_connect: %w", err) } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index c22c0e8852..718913a044 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -538,6 +538,19 @@ func TestLoadOpenAICompactModelFromEnv(t *testing.T) { require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel) } +func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + require.NoError(t, err) + require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled) + require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled) + require.Equal(t, int64(500_000), cfg.Gateway.Grok.FreeQuotaTokenLimit) + require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent) + require.Equal(t, 24, cfg.Gateway.Grok.FreeQuotaWindowHours) + require.Equal(t, 60, cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds) +} + func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) { resetViperWithJWTSecret(t) @@ -545,6 +558,7 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) { require.NoError(t, err) require.True(t, cfg.Gateway.OpenAIHTTP2.Enabled) require.True(t, cfg.Gateway.OpenAIHTTP2.AllowProxyFallbackToHTTP1) + require.False(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled) require.Equal(t, 2, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold) require.Equal(t, 60, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds) require.Equal(t, 600, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds) @@ -552,12 +566,14 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) { func TestLoadOpenAIProxyStreamCircuitFromEnv(t *testing.T) { resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_DISABLED", "true") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD", "3") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS", "90") t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS", "420") cfg, err := Load() require.NoError(t, err) + require.True(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled) require.Equal(t, 3, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold) require.Equal(t, 90, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds) require.Equal(t, 420, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index d79b49a4a7..148c1cd5d3 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3.1-flash-image": "gemini-3.1-flash-image", // Gemini 3.1 image preview 映射 "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image", + // Gemini 3.6 Flash tiered models + "gemini-3.6-flash": "gemini-3.6-flash", + "gemini-3.6-flash-high": "gemini-3.6-flash-high", + "gemini-3.6-flash-low": "gemini-3.6-flash-low", + "gemini-3.6-flash-medium": "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered", // Gemini 3 image 兼容映射(向 3.1 image 迁移) "gemini-3-pro-image": "gemini-3.1-flash-image", "gemini-3-pro-image-preview": "gemini-3.1-flash-image", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index 0fb9054f7e..e847e22f30 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) { + for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} { + if got := DefaultAntigravityModelMapping[model]; got != model { + t.Fatalf("expected %s to map to itself, got %q", model, got) + } + } +} + func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7cdedad2ed..30fc6b9828 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -147,6 +147,8 @@ type UpdateAccountRequest struct { GroupIDs *[]int64 `json:"group_ids"` ExpiresAt *int64 `json:"expires_at"` AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` + ProbeEnabled *bool `json:"upstream_billing_probe_enabled"` + RateSyncEnabled *bool `json:"upstream_billing_rate_sync_enabled"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } @@ -987,6 +989,8 @@ func (h *AccountHandler) Update(c *gin.Context) { GroupIDs: req.GroupIDs, ExpiresAt: req.ExpiresAt, AutoPauseOnExpired: req.AutoPauseOnExpired, + ProbeEnabled: req.ProbeEnabled, + RateSyncEnabled: req.RateSyncEnabled, SkipMixedChannelCheck: skipCheck, }) if err != nil { @@ -1061,6 +1065,10 @@ type TestAccountRequest struct { ModelID string `json:"model_id"` Prompt string `json:"prompt"` Mode string `json:"mode"` + // Optional media for Grok (and future) real generation tests. + // ImageDataURL / AudioDataURL are data:;base64,... payloads. + ImageDataURL string `json:"image_data_url"` + AudioDataURL string `json:"audio_data_url"` } type SyncFromCRSRequest struct { @@ -1090,8 +1098,13 @@ func (h *AccountHandler) Test(c *gin.Context) { // Allow empty body, model_id is optional _ = c.ShouldBindJSON(&req) + opts := service.AccountTestOptions{ + ImageDataURL: req.ImageDataURL, + AudioDataURL: req.AudioDataURL, + } + // Use AccountTestService to test the account with SSE streaming - if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil { + if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil { // Error already sent via SSE, just log return } @@ -1411,6 +1424,9 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { return } + // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk. + req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials) + updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{ Type: req.Type, Credentials: req.Credentials, @@ -1438,6 +1454,20 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { } } + // Successful re-auth clears the soft spending-limit reauth flag for Grok. + if existing.Platform == service.PlatformGrok { + if clearErr := h.adminService.UpdateAccountExtra(ctx, accountID, map[string]any{ + "grok_needs_reauth": false, + "grok_needs_reauth_reason": "", + "grok_needs_reauth_at": "", + }); clearErr != nil { + slog.Warn("apply_oauth_credentials.clear_grok_reauth_failed", + "account_id", accountID, + "err", clearErr, + ) + } + } + if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil { slog.Warn("apply_oauth_credentials.clear_error_failed", "account_id", accountID, @@ -1531,6 +1561,141 @@ func (h *AccountHandler) RevertProxyFallback(c *gin.Context) { response.Success(c, gin.H{"message": "reverted"}) } +// BatchDelete handles deleting multiple accounts with bounded concurrency. +// POST /api/v1/admin/accounts/batch-delete +func (h *AccountHandler) BatchDelete(c *gin.Context) { + var req struct { + AccountIDs []int64 `json:"account_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + accountIDs := normalizeInt64IDList(req.AccountIDs) + if len(accountIDs) == 0 { + response.BadRequest(c, "account_ids is required") + return + } + + accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + type deleteError struct { + AccountID int64 `json:"account_id"` + Error string `json:"error"` + } + + requestedIDs := make(map[int64]struct{}, len(accountIDs)) + for _, accountID := range accountIDs { + requestedIDs[accountID] = struct{}{} + } + accountsByID := make(map[int64]*service.Account, len(accounts)) + for _, account := range accounts { + if account != nil { + accountsByID[account.ID] = account + } + } + + rootIDs := make([]int64, 0, len(accountIDs)) + dependentIDs := make(map[int64][]int64) + failedIDs := make([]int64, 0) + errorsByAccount := make([]deleteError, 0) + for _, accountID := range accountIDs { + account := accountsByID[accountID] + if account == nil { + failedIDs = append(failedIDs, accountID) + errorsByAccount = append(errorsByAccount, deleteError{ + AccountID: accountID, + Error: "account not found", + }) + continue + } + + rootID := accountID + visited := map[int64]struct{}{accountID: {}} + for { + current := accountsByID[rootID] + if current == nil || current.ParentAccountID == nil { + break + } + parentID := *current.ParentAccountID + if _, selected := requestedIDs[parentID]; !selected { + break + } + if _, exists := accountsByID[parentID]; !exists { + break + } + if _, cyclic := visited[parentID]; cyclic { + rootID = accountID + break + } + visited[parentID] = struct{}{} + rootID = parentID + } + + if rootID != accountID { + dependentIDs[rootID] = append(dependentIDs[rootID], accountID) + continue + } + rootIDs = append(rootIDs, accountID) + } + + const maxConcurrency = 5 + g, gctx := errgroup.WithContext(c.Request.Context()) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + successIDs := make([]int64, 0, len(accountIDs)) + + // Every worker returns nil so one account failure does not cancel the remaining deletions. + for _, id := range rootIDs { + accountID := id + g.Go(func() error { + err := h.adminService.DeleteAccount(gctx, accountID) + + mu.Lock() + defer mu.Unlock() + affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...) + if err != nil { + for _, affectedID := range affectedIDs { + failedIDs = append(failedIDs, affectedID) + errorsByAccount = append(errorsByAccount, deleteError{ + AccountID: affectedID, + Error: err.Error(), + }) + } + return nil + } + successIDs = append(successIDs, affectedIDs...) + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] }) + sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] }) + sort.Slice(errorsByAccount, func(i, j int) bool { + return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID + }) + + response.Success(c, gin.H{ + "total": len(accountIDs), + "success": len(successIDs), + "failed": len(failedIDs), + "success_ids": successIDs, + "failed_ids": failedIDs, + "errors": errorsByAccount, + }) +} + // BatchClearError handles batch clearing account errors // POST /api/v1/admin/accounts/batch-clear-error func (h *AccountHandler) BatchClearError(c *gin.Context) { @@ -2283,6 +2448,11 @@ type BatchTodayStatsRequest struct { AccountIDs []int64 `json:"account_ids" binding:"required"` } +type BatchUsageRequest struct { + AccountIDs []int64 `json:"account_ids" binding:"required"` + Force bool `json:"force"` +} + // GetBatchTodayStats 批量获取多个账号的今日统计。 // POST /api/v1/admin/accounts/today-stats/batch func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) { @@ -2329,6 +2499,36 @@ func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) { response.Success(c, payload) } +// GetBatchUsage 批量获取多个账号的 current usage。 +// POST /api/v1/admin/accounts/usage/batch +func (h *AccountHandler) GetBatchUsage(c *gin.Context) { + var req BatchUsageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + accountIDs := normalizeInt64IDList(req.AccountIDs) + if len(accountIDs) == 0 { + response.Success(c, gin.H{ + "usage": map[string]any{}, + "errors": map[string]string{}, + }) + return + } + + usageByAccount, errorsByAccount, err := h.accountUsageService.GetUsageBatch(c.Request.Context(), accountIDs, req.Force) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "usage": usageByAccount, + "errors": errorsByAccount, + }) +} + // SetSchedulableRequest represents the request body for setting schedulable status type SetSchedulableRequest struct { Schedulable bool `json:"schedulable"` diff --git a/backend/internal/handler/admin/account_handler_batch_delete_test.go b/backend/internal/handler/admin/account_handler_batch_delete_test.go new file mode 100644 index 0000000000..8773557065 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_batch_delete_test.go @@ -0,0 +1,173 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type batchDeleteAdminService struct { + *stubAdminService + + mu sync.Mutex + active int + maxActive int + deletedIDs []int64 + deleteErrorsByID map[int64]error + accountsByID map[int64]*service.Account +} + +func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) { + accounts := make([]*service.Account, 0, len(ids)) + for _, id := range ids { + if s.accountsByID != nil { + if account, ok := s.accountsByID[id]; ok { + accounts = append(accounts, account) + } + continue + } + accounts = append(accounts, &service.Account{ID: id}) + } + return accounts, nil +} + +func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error { + s.mu.Lock() + s.active++ + if s.active > s.maxActive { + s.maxActive = s.active + } + s.mu.Unlock() + + select { + case <-ctx.Done(): + s.mu.Lock() + s.active-- + s.mu.Unlock() + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + + s.mu.Lock() + defer s.mu.Unlock() + s.active-- + s.deletedIDs = append(s.deletedIDs, id) + return s.deleteErrorsByID[id] +} + +func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete) + return router +} + +func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) { + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + deleteErrorsByID: map[int64]error{ + 3: errors.New("delete failed"), + }, + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var payload struct { + Data struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + SuccessIDs []int64 `json:"success_ids"` + FailedIDs []int64 `json:"failed_ids"` + Errors []struct { + AccountID int64 `json:"account_id"` + Error string `json:"error"` + } `json:"errors"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, 5, payload.Data.Total) + require.Equal(t, 4, payload.Data.Success) + require.Equal(t, 1, payload.Data.Failed) + require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs) + require.Equal(t, []int64{3}, payload.Data.FailedIDs) + require.Equal(t, int64(3), payload.Data.Errors[0].AccountID) + require.Equal(t, "delete failed", payload.Data.Errors[0].Error) + require.LessOrEqual(t, adminSvc.maxActive, 5) + require.Greater(t, adminSvc.maxActive, 1) +} + +func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) { + parentID := int64(1) + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + accountsByID: map[int64]*service.Account{ + 1: {ID: 1}, + 2: {ID: 2, ParentAccountID: &parentID}, + 3: {ID: 3}, + }, + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[1,2,3]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var payload struct { + Data struct { + SuccessIDs []int64 `json:"success_ids"` + FailedIDs []int64 `json:"failed_ids"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs) + require.Empty(t, payload.Data.FailedIDs) + require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs) +} + +func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) { + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[0,-1]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/backend/internal/handler/admin/account_handler_long_context_billing_test.go b/backend/internal/handler/admin/account_handler_long_context_billing_test.go index b3d3db9667..4d40e93bf7 100644 --- a/backend/internal/handler/admin/account_handler_long_context_billing_test.go +++ b/backend/internal/handler/admin/account_handler_long_context_billing_test.go @@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) { gin.SetMode(gin.TestMode) - handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil) + handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) router := gin.New() router.Use(gin.Recovery()) router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT) diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go index bc4a3cf49b..9e29b5893b 100644 --- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go +++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go @@ -147,6 +147,28 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T require.False(t, hasRequireConfirmation) } +func TestAccountHandlerUpdateMapsUpstreamBillingRateSyncSettings(t *testing.T) { + adminSvc := newStubAdminService() + router := setupAccountMixedChannelRouter(adminSvc) + body, _ := json.Marshal(map[string]any{ + "name": "gemini-key", + "upstream_billing_probe_enabled": true, + "upstream_billing_rate_sync_enabled": true, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/42", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, adminSvc.lastUpdateAccountInput) + require.NotNil(t, adminSvc.lastUpdateAccountInput.ProbeEnabled) + require.True(t, *adminSvc.lastUpdateAccountInput.ProbeEnabled) + require.NotNil(t, adminSvc.lastUpdateAccountInput.RateSyncEnabled) + require.True(t, *adminSvc.lastUpdateAccountInput.RateSyncEnabled) +} + func TestAccountHandlerBulkUpdateMixedChannelConflict(t *testing.T) { adminSvc := newStubAdminService() adminSvc.bulkUpdateAccountErr = &service.MixedChannelError{ diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 0eb661b932..4c3beb9b8a 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -32,6 +32,7 @@ type stubAdminService struct { createAccountErr error createSparkShadowErr error updateAccountErr error + lastUpdateAccountInput *service.UpdateAccountInput bulkUpdateAccountErr error lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput getAccountResult *service.Account @@ -501,6 +502,7 @@ func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64 func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { s.updateAccountCalls++ + s.lastUpdateAccountInput = input if s.updateAccountErr != nil { return nil, s.updateAccountErr } diff --git a/backend/internal/handler/admin/backup_handler.go b/backend/internal/handler/admin/backup_handler.go index 6c51e2f776..b668eda82a 100644 --- a/backend/internal/handler/admin/backup_handler.go +++ b/backend/internal/handler/admin/backup_handler.go @@ -153,12 +153,12 @@ func (h *BackupHandler) GetDownloadURL(c *gin.Context) { response.BadRequest(c, "backup ID is required") return } - url, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID) + download, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID) if err != nil { response.ErrorFrom(c, err) return } - response.Success(c, gin.H{"url": url}) + response.Success(c, download) } // ─── 恢复操作(需要重新输入管理员密码) ─── diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index f5442448c8..ade8f0c95c 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -33,7 +33,7 @@ type createChannelRequest struct { GroupIDs []int64 `json:"group_ids"` ModelPricing []channelModelPricingRequest `json:"model_pricing"` ModelMapping map[string]map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"` RestrictModels bool `json:"restrict_models"` Features string `json:"features"` FeaturesConfig map[string]any `json:"features_config"` @@ -48,7 +48,7 @@ type updateChannelRequest struct { GroupIDs *[]int64 `json:"group_ids"` ModelPricing *[]channelModelPricingRequest `json:"model_pricing"` ModelMapping map[string]map[string]string `json:"model_mapping"` - BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"` + BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"` RestrictModels *bool `json:"restrict_models"` Features *string `json:"features"` FeaturesConfig map[string]any `json:"features_config"` diff --git a/backend/internal/handler/admin/content_moderation_handler.go b/backend/internal/handler/admin/content_moderation_handler.go index 00a8dca05f..cafcaeec4e 100644 --- a/backend/internal/handler/admin/content_moderation_handler.go +++ b/backend/internal/handler/admin/content_moderation_handler.go @@ -20,10 +20,12 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content } type contentModerationConfigRequest struct { - Enabled *bool `json:"enabled"` - Mode *string `json:"mode"` - BaseURL *string `json:"base_url"` - Model *string `json:"model"` + Enabled *bool `json:"enabled"` + Mode *string `json:"mode"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + // 审计请求使用的代理服务器:null 不修改;0 清除(直连);>0 指定代理。 + ProxyID *int64 `json:"proxy_id"` APIKey *string `json:"api_key"` APIKeys *[]string `json:"api_keys"` APIKeysMode string `json:"api_keys_mode"` @@ -60,6 +62,7 @@ type contentModerationAPIKeyTestRequest struct { BaseURL string `json:"base_url"` Model string `json:"model"` TimeoutMS int `json:"timeout_ms"` + ProxyID *int64 `json:"proxy_id"` Prompt string `json:"prompt"` Images []string `json:"images"` } @@ -88,6 +91,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) { Mode: req.Mode, BaseURL: req.BaseURL, Model: req.Model, + ProxyID: req.ProxyID, APIKey: req.APIKey, APIKeys: req.APIKeys, APIKeysMode: req.APIKeysMode, @@ -134,6 +138,7 @@ func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) { BaseURL: req.BaseURL, Model: req.Model, TimeoutMS: req.TimeoutMS, + ProxyID: req.ProxyID, Prompt: req.Prompt, Images: req.Images, }) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 8f55fb4165..c94ed033fa 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) { return startTime, endTime } +func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) { + raw := strings.TrimSpace(c.Query(name)) + if raw == "" { + return nil, nil + } + value, err := strconv.ParseBool(raw) + if err != nil { + return nil, err + } + return &value, nil +} + // GetStats handles getting dashboard statistics // GET /api/v1/admin/dashboard/stats func (h *DashboardHandler) GetStats(c *gin.Context) { @@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType) + trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get usage trend") return @@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType) + stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get model statistics") return @@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) { var requestType *int16 var stream *bool var billingType *int8 + var upstreamModelMismatch *bool if userIDStr := c.Query("user_id"); userIDStr != "" { if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil { @@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) { return } } + upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } - stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get group statistics") return diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 6056f725b5..3316557dba 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct { trendStream *bool modelRequestType *int16 modelStream *bool + trendMismatch *bool + modelMismatch *bool + groupMismatch *bool rankingLimit int ranking []usagestats.UserSpendingRankingItem rankingTotal float64 } +func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters( + ctx context.Context, + startTime, endTime time.Time, + granularity string, + filters usagestats.UsageLogFilters, +) ([]usagestats.TrendDataPoint, error) { + s.trendRequestType = filters.RequestType + s.trendStream = filters.Stream + s.trendMismatch = filters.UpstreamModelMismatch + return []usagestats.TrendDataPoint{}, nil +} + func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters( ctx context.Context, startTime, endTime time.Time, @@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters( return []usagestats.TrendDataPoint{}, nil } +func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource( + ctx context.Context, + startTime, endTime time.Time, + filters usagestats.UsageLogFilters, + source string, +) ([]usagestats.ModelStat, error) { + s.modelRequestType = filters.RequestType + s.modelStream = filters.Stream + s.modelMismatch = filters.UpstreamModelMismatch + return []usagestats.ModelStat{}, nil +} + +func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters( + ctx context.Context, + startTime, endTime time.Time, + filters usagestats.UsageLogFilters, +) ([]usagestats.GroupStat, error) { + s.groupMismatch = filters.UpstreamModelMismatch + return []usagestats.GroupStat{}, nil +} + func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters( ctx context.Context, startTime, endTime time.Time, @@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng router := gin.New() router.GET("/admin/dashboard/trend", handler.GetUsageTrend) router.GET("/admin/dashboard/models", handler.GetModelStats) + router.GET("/admin/dashboard/groups", handler.GetGroupStats) router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking) return router } @@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) } +func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) { + resetDashboardReadCachesForTest() + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?upstream_model_mismatch=true", + "/admin/dashboard/models?upstream_model_mismatch=true", + "/admin/dashboard/groups?upstream_model_mismatch=true", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, path) + } + + require.NotNil(t, repo.trendMismatch) + require.True(t, *repo.trendMismatch) + require.NotNil(t, repo.modelMismatch) + require.True(t, *repo.modelMismatch) + require.NotNil(t, repo.groupMismatch) + require.True(t, *repo.groupMismatch) +} + +func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?upstream_model_mismatch=invalid", + "/admin/dashboard/models?upstream_model_mismatch=invalid", + "/admin/dashboard/groups?upstream_model_mismatch=invalid", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, path) + } +} + func TestDashboardUsersRankingLimitAndCache(t *testing.T) { dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute) repo := &dashboardUsageRepoCapture{ diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 815c516154..4f79fb4500 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -18,30 +18,32 @@ var ( ) type dashboardTrendCacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Granularity string `json:"granularity"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Granularity string `json:"granularity"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } type dashboardModelGroupCacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - ModelSource string `json:"model_source,omitempty"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + ModelSource string `json:"model_source,omitempty"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } type dashboardEntityTrendCacheKey struct { @@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.TrendDataPoint, bool, error) { key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - Granularity: granularity, - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - Model: model, - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + Granularity: granularity, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType) + return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + Model: model, RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }) }) if err != nil { return nil, hit, err @@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.ModelStat, bool, error) { key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - ModelSource: usagestats.NormalizeModelSource(modelSource), - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + ModelSource: usagestats.NormalizeModelSource(modelSource), + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource) + return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }, modelSource) }) if err != nil { return nil, hit, err @@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached( requestType *int16, stream *bool, billingType *int8, + upstreamModelMismatch *bool, ) ([]usagestats.GroupStat, bool, error) { key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - RequestType: requestType, - Stream: stream, - BillingType: billingType, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) + return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{ + UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestType: requestType, Stream: stream, BillingType: billingType, + UpstreamModelMismatch: upstreamModelMismatch, + }) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 517ae7bd14..b19c9360aa 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct { } type dashboardSnapshotV2Filters struct { - UserID int64 - APIKeyID int64 - AccountID int64 - GroupID int64 - Model string - RequestType *int16 - Stream *bool - BillingType *int8 + UserID int64 + APIKeyID int64 + AccountID int64 + GroupID int64 + Model string + RequestType *int16 + Stream *bool + BillingType *int8 + UpstreamModelMismatch *bool } type dashboardSnapshotV2CacheKey struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Granularity string `json:"granularity"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` - IncludeStats bool `json:"include_stats"` - IncludeTrend bool `json:"include_trend"` - IncludeModels bool `json:"include_models"` - IncludeGroups bool `json:"include_groups"` - IncludeUsersTrend bool `json:"include_users_trend"` - UsersTrendLimit int `json:"users_trend_limit"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Granularity string `json:"granularity"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` + IncludeStats bool `json:"include_stats"` + IncludeTrend bool `json:"include_trend"` + IncludeModels bool `json:"include_models"` + IncludeGroups bool `json:"include_groups"` + IncludeUsersTrend bool `json:"include_users_trend"` + UsersTrendLimit int `json:"users_trend_limit"` } func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) { @@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) { } keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{ - StartTime: startTime.UTC().Format(time.RFC3339), - EndTime: endTime.UTC().Format(time.RFC3339), - Granularity: granularity, - UserID: filters.UserID, - APIKeyID: filters.APIKeyID, - AccountID: filters.AccountID, - GroupID: filters.GroupID, - Model: filters.Model, - RequestType: filters.RequestType, - Stream: filters.Stream, - BillingType: filters.BillingType, - IncludeStats: includeStats, - IncludeTrend: includeTrend, - IncludeModels: includeModels, - IncludeGroups: includeGroups, - IncludeUsersTrend: includeUsersTrend, - UsersTrendLimit: usersTrendLimit, + StartTime: startTime.UTC().Format(time.RFC3339), + EndTime: endTime.UTC().Format(time.RFC3339), + Granularity: granularity, + UserID: filters.UserID, + APIKeyID: filters.APIKeyID, + AccountID: filters.AccountID, + GroupID: filters.GroupID, + Model: filters.Model, + RequestType: filters.RequestType, + Stream: filters.Stream, + BillingType: filters.BillingType, + UpstreamModelMismatch: filters.UpstreamModelMismatch, + IncludeStats: includeStats, + IncludeTrend: includeTrend, + IncludeModels: includeModels, + IncludeGroups: includeGroups, + IncludeUsersTrend: includeUsersTrend, + UsersTrendLimit: usersTrendLimit, }) cacheKey := string(keyRaw) @@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get usage trend") @@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get model statistics") @@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.RequestType, filters.Stream, filters.BillingType, + filters.UpstreamModelMismatch, ) if err != nil { return nil, errors.New("failed to get group statistics") @@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter filters.BillingType = &bt } + if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" { + value, err := strconv.ParseBool(mismatchStr) + if err != nil { + return nil, err + } + filters.UpstreamModelMismatch = &value + } + return filters, nil } diff --git a/backend/internal/handler/admin/grok_import_probe.go b/backend/internal/handler/admin/grok_import_probe.go index 9ef4ce8cd0..da547b82f8 100644 --- a/backend/internal/handler/admin/grok_import_probe.go +++ b/backend/internal/handler/admin/grok_import_probe.go @@ -13,6 +13,7 @@ import ( const ( grokImportProbeConcurrency = 3 grokImportProbeTimeout = 25 * time.Second + grokImportProbeQueueLimit = 64 ) type grokImportProber interface { @@ -27,6 +28,8 @@ type grokImportProbeTask struct { type grokImportProbeScheduler struct { mu sync.Mutex queue []grokImportProbeTask + pending map[int64]struct{} + inFlight map[int64]struct{} concurrency int workers int maxWorkers int @@ -48,6 +51,8 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm return &grokImportProbeScheduler{ concurrency: concurrency, timeout: timeout, + pending: make(map[int64]struct{}), + inFlight: make(map[int64]struct{}), } } @@ -60,7 +65,21 @@ func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *se } s.mu.Lock() + if _, exists := s.pending[account.ID]; exists { + s.mu.Unlock() + return + } + if _, exists := s.inFlight[account.ID]; exists { + s.mu.Unlock() + return + } + if len(s.queue) >= grokImportProbeQueueLimit { + s.mu.Unlock() + slog.Debug("grok_import_active_probe_dropped", "account_id", account.ID, "reason", "queue_full") + return + } s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID}) + s.pending[account.ID] = struct{}{} if s.workers < s.concurrency { s.workers++ if s.workers > s.maxWorkers { @@ -78,6 +97,7 @@ func (s *grokImportProbeScheduler) worker() { return } s.run(task.prober, task.accountID) + s.finish(task.accountID) } } @@ -94,9 +114,17 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) { if len(s.queue) == 0 { s.queue = nil } + delete(s.pending, task.accountID) + s.inFlight[task.accountID] = struct{}{} return task, true } +func (s *grokImportProbeScheduler) finish(accountID int64) { + s.mu.Lock() + delete(s.inFlight, accountID) + s.mu.Unlock() +} + func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) { defer func() { if recovered := recover(); recovered != nil { @@ -108,8 +136,6 @@ func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) } }() - // Queue time is intentionally excluded: every imported account is probed, - // while this timeout only bounds the actual upstream probe execution. ctx, cancel := context.WithTimeout(context.Background(), s.timeout) defer cancel() result, err := prober.QueryQuota(ctx, accountID) diff --git a/backend/internal/handler/admin/grok_import_probe_handler_test.go b/backend/internal/handler/admin/grok_import_probe_handler_test.go index c671d2ff1c..a8b7d91250 100644 --- a/backend/internal/handler/admin/grok_import_probe_handler_test.go +++ b/backend/internal/handler/admin/grok_import_probe_handler_test.go @@ -4,6 +4,7 @@ package admin import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -62,6 +63,10 @@ func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, s return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil } +func (grokImportOAuthClientStub) LoginWithPassword(context.Context, string, string, string) (*service.GrokPasswordLoginResult, error) { + return nil, errors.New("unexpected password login") +} + func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil } diff --git a/backend/internal/handler/admin/grok_import_probe_test.go b/backend/internal/handler/admin/grok_import_probe_test.go index 255f04d0dc..f4d8567463 100644 --- a/backend/internal/handler/admin/grok_import_probe_test.go +++ b/backend/internal/handler/admin/grok_import_probe_test.go @@ -142,7 +142,7 @@ func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) { } func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) { - const taskCount = 100 + const taskCount = 50 release := make(chan struct{}) scheduler := newGrokImportProbeScheduler(3, time.Second) prober := newGrokImportProbeStub(taskCount) @@ -156,7 +156,7 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing. awaitGrokProbeSignal(t, prober.started) } snapshot := snapshotGrokImportProbeScheduler(scheduler) - require.Equal(t, 97, snapshot.queued) + require.Equal(t, taskCount-3, snapshot.queued) require.Equal(t, 3, snapshot.workers) require.Equal(t, 3, snapshot.maxWorkers) select { @@ -182,6 +182,55 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing. require.Equal(t, 3, snapshot.maxWorkers) } +func TestGrokImportProbeSchedulerDeduplicatesPendingAndInFlightAccounts(t *testing.T) { + scheduler := newGrokImportProbeScheduler(1, time.Second) + prober := newGrokImportProbeStub(2) + release := make(chan struct{}) + prober.block = release + account := newGrokOAuthImportAccount(501) + queued := newGrokOAuthImportAccount(502) + + scheduler.schedule(prober, account) + require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.started)) + scheduler.schedule(prober, account) + scheduler.schedule(prober, queued) + scheduler.schedule(prober, queued) + + scheduler.mu.Lock() + require.Len(t, scheduler.queue, 1) + require.Contains(t, scheduler.inFlight, int64(501)) + require.Contains(t, scheduler.pending, int64(502)) + scheduler.mu.Unlock() + + close(release) + require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.done)) + require.Equal(t, int64(502), awaitGrokProbeSignal(t, prober.done)) + calls, _, _ := prober.snapshot() + require.Equal(t, 1, calls[501]) + require.Equal(t, 1, calls[502]) +} + +func TestGrokImportProbeSchedulerBoundsPendingQueue(t *testing.T) { + scheduler := newGrokImportProbeScheduler(1, time.Second) + prober := newGrokImportProbeStub(grokImportProbeQueueLimit + 1) + release := make(chan struct{}) + prober.block = release + scheduler.schedule(prober, newGrokOAuthImportAccount(600)) + require.Equal(t, int64(600), awaitGrokProbeSignal(t, prober.started)) + for id := int64(601); id < 601+grokImportProbeQueueLimit+10; id++ { + scheduler.schedule(prober, newGrokOAuthImportAccount(id)) + } + + scheduler.mu.Lock() + require.Len(t, scheduler.queue, grokImportProbeQueueLimit) + scheduler.mu.Unlock() + + close(release) + for i := 0; i < grokImportProbeQueueLimit+1; i++ { + awaitGrokProbeSignal(t, prober.done) + } +} + func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) { neverRelease := make(chan struct{}) scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond) diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 1f679b9566..696a24b419 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct { RedirectURI string `json:"redirect_uri"` } +func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) { + response.Success(c, h.grokOAuthService.GetCapabilities()) +} + func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) { var req GrokGenerateAuthURLRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -95,6 +99,17 @@ type GrokRefreshTokenRequest struct { ProxyID *int64 `json:"proxy_id"` } +type GrokSSOTokenRequest struct { + SSOToken string `json:"sso_token"` + ProxyID *int64 `json:"proxy_id"` +} + +type GrokPasswordAuthorizeRequest struct { + Email string `json:"email"` + Password string `json:"password"` + ProxyID *int64 `json:"proxy_id"` +} + func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { var req GrokRefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -113,9 +128,15 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { var proxyURL string if req.ProxyID != nil { proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID) - if err == nil && proxy != nil { - proxyURL = proxy.URL() + if err != nil { + response.ErrorFrom(c, err) + return } + if proxy == nil { + response.BadRequest(c, "GROK_OAUTH_PROXY_NOT_FOUND: proxy not found") + return + } + proxyURL = proxy.URL() } tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID) if err != nil { @@ -125,6 +146,38 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) { response.Success(c, tokenInfo) } +// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens. +// Response contains OAuth token info only — never echoes sso_token. +func (h *GrokOAuthHandler) ValidateSSOToken(c *gin.Context) { + var req GrokSSOTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tokenInfo, err := h.grokOAuthService.ValidateSSOToken(c.Request.Context(), req.SSOToken, req.ProxyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, tokenInfo) +} + +// AuthorizePassword exchanges email/password for Build OAuth tokens via SSO conversion. +// Response never includes password or raw sso_token. +func (h *GrokOAuthHandler) AuthorizePassword(c *gin.Context) { + var req GrokPasswordAuthorizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tokenInfo, err := h.grokOAuthService.AuthorizePassword(c.Request.Context(), req.Email, req.Password, req.ProxyID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, tokenInfo) +} + func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -412,11 +465,38 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr // 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与 // RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。 func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any { - credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built) + // Only merge operator config from the request — never free-form secrets + // (password / sso_token / cookie / etc.) into stored credentials. + allowedReqKeys := map[string]struct{}{ + "base_url": {}, "model_mapping": {}, + "header_override": {}, "header_overrides": {}, "header_override_enabled": {}, + "custom_headers": {}, + } + ops := map[string]any{} + for k, v := range reqCredentials { + if _, ok := allowedReqKeys[k]; !ok { + continue + } + if service.IsSensitiveCredentialKey(k) { + continue + } + ops[k] = v + } + credentials := service.MergeCredentials(ops, built) + // Strip any sensitive keys that might have slipped in via older callers. + for k := range credentials { + if service.IsSensitiveCredentialKey(k) { + // Keep only keys produced by BuildAccountCredentials (tokens). + if k == "access_token" || k == "refresh_token" || k == "id_token" { + continue + } + delete(credentials, k) + } + } if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" { credentials["base_url"] = strings.TrimSpace(reqBaseURL) } - return credentials + return service.SanitizeStoredCredentials(service.PlatformGrok, credentials) } func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) { diff --git a/backend/internal/handler/admin/grok_oauth_handler_test.go b/backend/internal/handler/admin/grok_oauth_handler_test.go index 6214394882..873773220f 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -4,6 +4,7 @@ package admin import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -128,14 +130,22 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) { require.Contains(t, rec.Body.String(), `"snapshot":`) require.Contains(t, rec.Body.String(), `"headers_observed":true`) require.NotContains(t, rec.Body.String(), "access-token") + require.Eventually(t, func() bool { + upstream.mu.Lock() + defer upstream.mu.Unlock() + return len(upstream.requests) == 4 + }, time.Second, 10*time.Millisecond) upstream.mu.Lock() requests := append([]*http.Request(nil), upstream.requests...) bodies := append([][]byte(nil), upstream.bodies...) upstream.mu.Unlock() - require.Len(t, requests, 3) + require.Len(t, requests, 4) + responsesProbeSeen := false + modelsSyncSeen := false for i, upstreamReq := range requests { require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization")) if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" { + responsesProbeSeen = true require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept")) require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`) require.Contains(t, string(bodies[i]), `"input":"hi"`) @@ -143,7 +153,12 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) { require.NotContains(t, string(bodies[i]), `"max_output_tokens"`) require.NotContains(t, string(bodies[i]), `"store"`) } + if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/models" { + modelsSyncSeen = true + } } + require.True(t, responsesProbeSeen) + require.True(t, modelsSyncSeen) require.NotNil(t, repo.updates[42]) } @@ -189,6 +204,84 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) { require.NotContains(t, rec.Body.String(), "client-secret-like-value") } +type grokOAuthHandlerClient struct{} + +func (c *grokOAuthHandlerClient) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) { + return nil, errors.New("unexpected exchange") +} + +func (c *grokOAuthHandlerClient) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) { + return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil +} + +func (c *grokOAuthHandlerClient) LoginWithPassword(_ context.Context, email, _ string, _ string) (*service.GrokPasswordLoginResult, error) { + return &service.GrokPasswordLoginResult{ + Email: email, + SSOToken: "sso-from-password", + }, nil +} + +func (c *grokOAuthHandlerClient) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { + return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil +} + +func TestGrokOAuthHandlerValidateSSOTokenReturnsTokenInfo(t *testing.T) { + gin.SetMode(gin.TestMode) + + oauthClient := &grokOAuthHandlerClient{} + oauthService := service.NewGrokOAuthService(nil, oauthClient) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/grok/oauth/sso-token", handler.ValidateSSOToken) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/sso-token", strings.NewReader(`{"sso_token":"sso-token"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"access_token":"access-token"`) + require.NotContains(t, rec.Body.String(), `"sso_token"`) +} + +func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *testing.T) { + gin.SetMode(gin.TestMode) + + oauthClient := &grokOAuthHandlerClient{} + cfg := &config.Config{} + cfg.Gateway.Grok.PasswordAuthEnabled = true + oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/grok/oauth/password", handler.AuthorizePassword) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/password", strings.NewReader(`{"email":"user@example.com","password":"super-secret"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"access_token":"access-token"`) + require.NotContains(t, rec.Body.String(), "super-secret") +} + +func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{}) + defer oauthService.Stop() + handler := NewGrokOAuthHandler(oauthService, nil, nil, nil) + + router := gin.New() + router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`) +} + func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) { tokenExpiry := time.Now().Add(6 * time.Hour).Unix() expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{ @@ -266,14 +359,12 @@ func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) { require.Equal(t, "at-2", credentials["access_token"]) } -func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) { +func TestGrokSSOImportWorkerHandlesMissingOAuthService(t *testing.T) { h := &GrokOAuthHandler{} result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3) - // Without a service, createAccountFromSSOToken would panic on nil service access. - // Recovery must convert that into a failed item and keep the worker alive. require.False(t, result.created) require.Equal(t, 2, result.item.Index) - require.Contains(t, result.item.Error, "internal worker panic") + require.Contains(t, result.item.Error, "GROK_OAUTH_CLIENT_NOT_CONFIGURED") } func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) { diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 3646f5ab3a..8ddbfef183 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -106,28 +106,36 @@ type CreateGroupRequest struct { WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) - AllowImageGeneration bool `json:"allow_image_generation"` - AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier *float64 `json:"image_rate_multiplier"` - BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` - BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` - VideoRateIndependent bool `json:"video_rate_independent"` - VideoRateMultiplier *float64 `json:"video_rate_multiplier"` - PeakRateEnabled bool `json:"peak_rate_enabled"` - PeakStart string `json:"peak_start"` - PeakEnd string `json:"peak_end"` - PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ImagePrice1K *float64 `json:"image_price_1k"` - ImagePrice2K *float64 `json:"image_price_2k"` - ImagePrice4K *float64 `json:"image_price_4k"` - VideoPrice480P *float64 `json:"video_price_480p"` - VideoPrice720P *float64 `json:"video_price_720p"` - VideoPrice1080P *float64 `json:"video_price_1080p"` - WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` - ClaudeCodeOnly bool `json:"claude_code_only"` - FallbackGroupID *int64 `json:"fallback_group_id"` - FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` + AllowImageGeneration bool `json:"allow_image_generation"` + AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` + BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` + BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier *float64 `json:"video_rate_multiplier"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` + VideoPrice480P *float64 `json:"video_price_480p"` + VideoPrice720P *float64 `json:"video_price_720p"` + VideoPrice1080P *float64 `json:"video_price_1080p"` + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` + AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` + AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` + AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` + ClaudeCodeOnly bool `json:"claude_code_only"` + FallbackGroupID *int64 `json:"fallback_group_id"` + FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled bool `json:"model_routing_enabled"` @@ -165,28 +173,36 @@ type UpdateGroupRequest struct { WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) - AllowImageGeneration *bool `json:"allow_image_generation"` - AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"` - ImageRateIndependent *bool `json:"image_rate_independent"` - ImageRateMultiplier *float64 `json:"image_rate_multiplier"` - BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` - BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` - VideoRateIndependent *bool `json:"video_rate_independent"` - VideoRateMultiplier *float64 `json:"video_rate_multiplier"` - PeakRateEnabled *bool `json:"peak_rate_enabled"` - PeakStart *string `json:"peak_start"` - PeakEnd *string `json:"peak_end"` - PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ImagePrice1K *float64 `json:"image_price_1k"` - ImagePrice2K *float64 `json:"image_price_2k"` - ImagePrice4K *float64 `json:"image_price_4k"` - VideoPrice480P *float64 `json:"video_price_480p"` - VideoPrice720P *float64 `json:"video_price_720p"` - VideoPrice1080P *float64 `json:"video_price_1080p"` - WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` - ClaudeCodeOnly *bool `json:"claude_code_only"` - FallbackGroupID *int64 `json:"fallback_group_id"` - FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` + AllowImageGeneration *bool `json:"allow_image_generation"` + AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"` + ImageRateIndependent *bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` + BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"` + BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"` + VideoRateIndependent *bool `json:"video_rate_independent"` + VideoRateMultiplier *float64 `json:"video_rate_multiplier"` + PeakRateEnabled *bool `json:"peak_rate_enabled"` + PeakStart *string `json:"peak_start"` + PeakEnd *string `json:"peak_end"` + PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` + ProfitControlEnabled *bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` + VideoPrice480P *float64 `json:"video_price_480p"` + VideoPrice720P *float64 `json:"video_price_720p"` + VideoPrice1080P *float64 `json:"video_price_1080p"` + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` + AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` + AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` + AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` + ClaudeCodeOnly *bool `json:"claude_code_only"` + FallbackGroupID *int64 `json:"fallback_group_id"` + FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled *bool `json:"model_routing_enabled"` @@ -475,6 +491,13 @@ func (h *GroupHandler) Create(c *gin.Context) { return } + // platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台, + // 否则省略 platform 的请求会被误判成「平台不支持利润控制」。 + if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil { + response.BadRequest(c, err.Error()) + return + } + group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ Name: req.Name, Description: req.Description, @@ -497,13 +520,21 @@ func (h *GroupHandler) Create(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, + VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, + SearchPricePer1k: req.SearchPricePer1k, + AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, + AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, + AudioSTTPricePerHour: req.AudioSttPricePerHour, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, @@ -616,13 +647,21 @@ func (h *GroupHandler) Update(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, VideoPrice480P: req.VideoPrice480P, VideoPrice720P: req.VideoPrice720P, VideoPrice1080P: req.VideoPrice1080P, + VideoModelPrices: req.VideoModelPrices, WebSearchPricePerCall: req.WebSearchPricePerCall, + SearchPricePer1k: req.SearchPricePer1k, + AudioRealtimePricePerMin: req.AudioRealtimePricePerMin, + AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars, + AudioSTTPricePerHour: req.AudioSttPricePerHour, ClaudeCodeOnly: req.ClaudeCodeOnly, FallbackGroupID: req.FallbackGroupID, FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 78d57299b6..d7cfc8d2a6 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -1,6 +1,9 @@ package admin import ( + "context" + "log/slog" + "net/http" "strconv" "strings" "time" @@ -17,7 +20,61 @@ import ( type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService - quotaService *service.OpenAIQuotaService + quotaService openAIQuotaService + rateLimitService openAIAccountStateRecoverer +} + +type openAIQuotaService interface { + QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error + ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) +} + +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 +// the browser aborts a mutation that already succeeded and the operator retries +// it — spending a second credit. +const openAIQuotaResetPostProcessTimeout = 8 * time.Second + +type openAIQuotaResetResponse struct { + service.OpenAIQuotaResetResult + Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"` + Account *dto.Account `json:"account,omitempty"` + CacheRefreshed bool `json:"cache_refreshed"` + AccountStateRecovered bool `json:"account_state_recovered"` + WarningCode string `json:"warning_code,omitempty"` +} + +// openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota +// query. The usage payload is embedded so the shape stays identical to the plain +// query; cache_persisted reports whether the snapshot write succeeded, because a +// failed display-cache write must never discard a successful upstream read. +type openAIQuotaRefreshResponse struct { + service.OpenAIQuotaUsage + CachePersisted bool `json:"cache_persisted"` +} + +// openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the +// client connection. The credit is already spent at that point, so account-state +// recovery must complete even if the operator closes the tab (mirrors +// systemUpdateContext, added for the same reason in #4504). +func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) { + base := context.Background() + if ctx != nil { + base = context.WithoutCancel(ctx) + } + return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout) } func oauthPlatformFromPath(c *gin.Context) string { @@ -29,12 +86,22 @@ func NewOpenAIOAuthHandler( openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService, quotaService *service.OpenAIQuotaService, + rateLimitService *service.RateLimitService, ) *OpenAIOAuthHandler { - return &OpenAIOAuthHandler{ + h := &OpenAIOAuthHandler{ openaiOAuthService: openaiOAuthService, adminService: adminService, - quotaService: quotaService, } + // Assign through explicit nil checks: storing a nil *Service in an interface + // field yields a non-nil interface, which would silently defeat the + // `== nil` capability guards below and panic instead of returning 400. + if quotaService != nil { + h.quotaService = quotaService + } + if rateLimitService != nil { + h.rateLimitService = rateLimitService + } + return h } // OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL @@ -420,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.BadRequest(c, "openai quota service is not enabled") return } + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) if err != nil { response.ErrorFrom(c, err) @@ -428,6 +496,47 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.Success(c, usage) } +// RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit +// snapshot so the card can be rehydrated without an upstream round-trip. +// POST /api/v1/admin/openai/accounts/:id/quota/refresh +// +// It is a POST (not a GET with a side-effect flag) because it writes account +// state: the audit middleware only records mutating verbs, so a persisting GET +// would mutate the database without an audit trail. +func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.quotaService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if usage == nil { + response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result") + return + } + + refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} + // A failed snapshot write leaves the previous cache intact — report it as a + // partial success instead of discarding the usage payload we just fetched, + // which would leave the card without a credit count at all. + if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err) + response.Success(c, refreshResponse) + return + } + refreshResponse.CachePersisted = true + response.Success(c, refreshResponse) +} + // CreateShadowRequest is the request body for CreateShadow. type CreateShadowRequest struct { Name string `json:"name"` @@ -482,5 +591,63 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, result) + if result == nil { + response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result") + return + } + + resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result} + 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 + } + 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 new file mode 100644 index 0000000000..c0e3ca161d --- /dev/null +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -0,0 +1,486 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type openAIQuotaWorkflowStub struct { + resetResult *service.OpenAIQuotaResetResult + resetErr error + queryResult *service.OpenAIQuotaUsage + queryErr error + cacheErr error + + resetCalls int + queryCalls int + cacheCalls int + + queryCtxErr error + cacheCtxErr error +} + +func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) { + s.resetCalls++ + return s.resetResult, s.resetErr +} + +func (s *openAIQuotaWorkflowStub) QueryUsage(ctx context.Context, _ int64) (*service.OpenAIQuotaUsage, error) { + s.queryCalls++ + s.queryCtxErr = ctx.Err() + return s.queryResult, s.queryErr +} + +func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, _ int64, _ *service.OpenAIRateLimitResetCredits) error { + s.cacheCalls++ + s.cacheCtxErr = ctx.Err() + return s.cacheErr +} + +type openAIAccountStateRecovererStub struct { + err error + calls int + accountID int64 + lastOptions service.AccountRecoveryOptions + lastCtxErr error +} + +func (s *openAIAccountStateRecovererStub) RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) { + s.calls++ + s.accountID = accountID + s.lastOptions = options + s.lastCtxErr = ctx.Err() + return &service.SuccessfulTestRecoveryResult{}, s.err +} + +type openAIResetAdminServiceStub struct { + service.AdminService + account *service.Account + err error + calls int +} + +func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) { + s.calls++ + return s.account, s.err +} + +type openAIQuotaResetEnvelope struct { + Code int `json:"code"` + Data openAIQuotaResetResponse `json:"data"` +} + +type openAIQuotaRefreshEnvelope struct { + Code int `json:"code"` + Data openAIQuotaRefreshResponse `json:"data"` +} + +func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) { + t.Helper() + return performOpenAIQuotaResetRequestWithContext(t, handler, nil) +} + +// performOpenAIQuotaResetRequestWithContext drives the reset endpoint, optionally +// with an already-canceled request context (client disconnect simulation). +func performOpenAIQuotaResetRequestWithContext(t *testing.T, handler *OpenAIOAuthHandler, ctx context.Context) (int, openAIQuotaResetEnvelope) { + t.Helper() + gin.SetMode(gin.TestMode) + + router := gin.New() + router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil) + if ctx != nil { + request = request.WithContext(ctx) + } + router.ServeHTTP(recorder, request) + + var envelope openAIQuotaResetEnvelope + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + return recorder.Code, envelope +} + +func performOpenAIQuotaRefreshRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaRefreshEnvelope) { + t.Helper() + gin.SetMode(gin.TestMode) + + router := gin.New() + router.POST("/api/v1/admin/openai/accounts/:id/quota/refresh", handler.RefreshQuota) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/quota/refresh", nil) + router.ServeHTTP(recorder, request) + + var envelope openAIQuotaRefreshEnvelope + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + return recorder.Code, envelope +} + +func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub { + return &openAIQuotaWorkflowStub{ + resetResult: &service.OpenAIQuotaResetResult{ + Code: "success", + WindowsReset: 1, + }, + queryResult: &service.OpenAIQuotaUsage{ + FetchedAt: 123, + RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{ + AvailableCount: 0, + Credits: []service.OpenAIRateLimitResetCreditDetail{}, + }, + }, + } +} + +func recoveredAccountStub() *openAIResetAdminServiceStub { + return &openAIResetAdminServiceStub{account: &service.Account{ + ID: 42, + Name: "recovered", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: false, + }} +} + +func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) { + quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")} + recoverer := &openAIAccountStateRecovererStub{} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + rateLimitService: recoverer, + } + + status, _ := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.Equal(t, 1, quota.resetCalls) + require.Zero(t, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, recoverer.calls) +} + +// Account-state recovery is the reason the credit was spent (#3672 / #3740), so it +// must run before — and independently of — the reset-credit display cache. +func TestOpenAIResetQuota_RecoversAccountStateBeforeRefreshingCache(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Empty(t, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.True(t, envelope.Data.CacheRefreshed) + require.NotNil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, int64(42), envelope.Data.Account.ID) + require.False(t, envelope.Data.Account.Schedulable, "manual scheduling switch must not be flipped") + require.Equal(t, int64(42), recoverer.accountID) + require.True(t, recoverer.lastOptions.InvalidateToken) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.Nil(t, envelope.Data.Account) + require.Equal(t, 1, recoverer.calls) + require.Zero(t, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, adminService.calls) +} + +func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.AccountStateRecovered) + require.Zero(t, quota.queryCalls) + require.Zero(t, adminService.calls) +} + +// A failed cache refresh must not hide the recovered account row: the operator +// still needs the list to drop the stale rate-limit badge. +func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + quota.queryErr = errors.New("upstream query failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, 1, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.cacheErr = errors.New("cache write failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.CacheRefreshed) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Quota) + require.Nil(t, envelope.Data.Account) + require.Equal(t, 1, adminService.calls) +} + +// The first (most actionable) failure wins so the UI never downgrades a cache +// problem into a cosmetic "could not reload the row" message. +func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.cacheErr = errors.New("cache write failed") + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.Nil(t, envelope.Data.Account) +} + +// The credit is non-refundable once consumed, so post-reset bookkeeping must +// survive a client disconnect instead of leaving the account rate-limited. +func TestOpenAIResetQuota_PostProcessingSurvivesClientCancellation(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + status, _ := performOpenAIQuotaResetRequestWithContext(t, handler, ctx) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, 1, recoverer.calls) + require.NoError(t, recoverer.lastCtxErr, "recovery must not inherit the canceled client context") + require.NoError(t, quota.queryCtxErr) + require.NoError(t, quota.cacheCtxErr) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIRefreshQuota_PersistsSnapshot(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.True(t, envelope.Data.CachePersisted) + require.Equal(t, int64(123), envelope.Data.FetchedAt) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Zero(t, quota.resetCalls) +} + +// A rejected snapshot write must never discard the usage payload: otherwise the +// card loses its credit count and the reset button stays disabled forever. +func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = &service.OpenAIQuotaUsage{ + FetchedAt: 456, + RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{ + AvailableCount: 2, + }, + } + quota.cacheErr = errors.New("expiration details unavailable") + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.False(t, envelope.Data.CachePersisted) + require.Equal(t, int64(456), envelope.Data.FetchedAt) + require.NotNil(t, envelope.Data.RateLimitResetCredits) + require.Equal(t, 2, envelope.Data.RateLimitResetCredits.AvailableCount) + require.Equal(t, 1, quota.cacheCalls) +} + +// An empty-but-successful upstream read must not be dereferenced blindly. +func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { + t.Run("refresh reports an internal error", func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, envelope := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.False(t, envelope.Data.CachePersisted) + require.Zero(t, quota.cacheCalls) + }) + + t.Run("reset degrades to a cache warning", func(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + recoverer := &openAIAccountStateRecovererStub{} + adminService := recoveredAccountStub() + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Account) + require.Zero(t, quota.cacheCalls) + }) +} + +func TestOpenAIRefreshQuota_QueryFailureIsReported(t *testing.T) { + quota := &openAIQuotaWorkflowStub{queryErr: errors.New("upstream query failed")} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + } + + status, _ := performOpenAIQuotaRefreshRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.Equal(t, 1, quota.queryCalls) + require.Zero(t, quota.cacheCalls) +} + +// Storing a nil *Service in an interface field would make the capability guards +// non-nil and panic on the first call; the constructor must keep them nil. +func TestNewOpenAIOAuthHandlerKeepsNilQuotaCapabilitiesGuarded(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) + require.Nil(t, handler.quotaService) + require.Nil(t, handler.rateLimitService) + + router := gin.New() + router.Use(gin.Recovery()) + router.GET("/openai/accounts/:id/quota", handler.QueryQuota) + router.POST("/openai/accounts/:id/quota/refresh", handler.RefreshQuota) + router.POST("/openai/accounts/:id/reset-quota", handler.ResetQuota) + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/openai/accounts/42/quota"}, + {http.MethodPost, "/openai/accounts/42/quota/refresh"}, + {http.MethodPost, "/openai/accounts/42/reset-quota"}, + } { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.path, nil)) + require.Equal(t, http.StatusBadRequest, recorder.Code, "%s %s", tc.method, tc.path) + } +} diff --git a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go index af3b245c4b..da856b3926 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go @@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { func TestCreateShadow_InvalidID(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { func TestCreateShadow_BadBody(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 703d297fa2..a5f3d41d90 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -54,6 +54,7 @@ type SettingHandler struct { settingService *service.SettingService emailService *service.EmailService turnstileService *service.TurnstileService + aliyunCaptchaService *service.AliyunCaptchaService opsService *service.OpsService paymentConfigService *service.PaymentConfigService paymentService *service.PaymentService @@ -82,6 +83,12 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s h.notificationEmailService = notificationEmailService } +// SetAliyunCaptchaService attaches the Aliyun captcha credential validator without +// changing the constructor signature used by existing unit tests. +func (h *SettingHandler) SetAliyunCaptchaService(aliyunCaptchaService *service.AliyunCaptchaService) { + h.aliyunCaptchaService = aliyunCaptchaService +} + // SetStepUpDeps attaches the services backing the step-up switch preconditions // (enable requires the acting admin to have TOTP enabled; disable is itself a // step-up gated operation), without changing the constructor signature used by @@ -129,6 +136,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, FrontendURL: settings.FrontendURL, @@ -156,6 +164,18 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured, + TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured, + TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured, + TencentCaptchaRegion: settings.TencentCaptchaRegion, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, @@ -233,6 +253,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, HomeContent: settings.HomeContent, + CompactHomeEnabled: settings.CompactHomeEnabled, HideCcsImportButton: settings.HideCcsImportButton, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, @@ -278,6 +299,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, OpenAICodexUserAgent: settings.OpenAICodexUserAgent, + OpenAICodexClientVersion: settings.OpenAICodexClientVersion, + OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled, MinCodexVersion: settings.MinCodexVersion, MaxCodexVersion: settings.MaxCodexVersion, CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, @@ -347,7 +371,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, + + GrokDefaultTextModel: settings.GrokDefaultTextModel, + GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, + GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, @@ -357,7 +387,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AffiliateEnabled: settings.AffiliateEnabled, - AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, + AccountSchedulingThresholds: settings.AccountSchedulingThresholds, + AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, } // OpenAI fast policy (stored under a dedicated setting key) diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 30c2495af4..d6110f43d5 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -41,6 +41,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) { changed = append(changed, "registration_email_suffix_whitelist") } + if before.RegistrationEmailDomainQuotaEnabled != after.RegistrationEmailDomainQuotaEnabled { + changed = append(changed, "registration_email_domain_quota_enabled") + } if before.PromoCodeEnabled != after.PromoCodeEnabled { changed = append(changed, "promo_code_enabled") } @@ -107,6 +110,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if req.TurnstileSecretKey != "" { changed = append(changed, "turnstile_secret_key") } + if before.TencentCaptchaEnabled != after.TencentCaptchaEnabled { + changed = append(changed, "tencent_captcha_enabled") + } + if before.TencentCaptchaAppID != after.TencentCaptchaAppID { + changed = append(changed, "tencent_captcha_app_id") + } + if req.TencentCaptchaAppSecretKey != "" { + changed = append(changed, "tencent_captcha_app_secret_key") + } + if req.TencentCaptchaCloudSecretID != "" { + changed = append(changed, "tencent_captcha_cloud_secret_id") + } + if req.TencentCaptchaCloudSecretKey != "" { + changed = append(changed, "tencent_captcha_cloud_secret_key") + } + if before.TencentCaptchaRegion != after.TencentCaptchaRegion { + changed = append(changed, "tencent_captcha_region") + } + if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled { + changed = append(changed, "aliyun_captcha_enabled") + } + if before.AliyunCaptchaAccessKeyID != after.AliyunCaptchaAccessKeyID { + changed = append(changed, "aliyun_captcha_access_key_id") + } + if req.AliyunCaptchaAccessKeySecret != "" { + changed = append(changed, "aliyun_captcha_access_key_secret") + } + if before.AliyunCaptchaSceneID != after.AliyunCaptchaSceneID { + changed = append(changed, "aliyun_captcha_scene_id") + } + if before.AliyunCaptchaPrefix != after.AliyunCaptchaPrefix { + changed = append(changed, "aliyun_captcha_prefix") + } + if before.AliyunCaptchaRegion != after.AliyunCaptchaRegion { + changed = append(changed, "aliyun_captcha_region") + } if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { changed = append(changed, "api_key_acl_trust_forwarded_ip") } @@ -299,6 +338,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.HomeContent != after.HomeContent { changed = append(changed, "home_content") } + if before.CompactHomeEnabled != after.CompactHomeEnabled { + changed = append(changed, "compact_home_enabled") + } if before.HideCcsImportButton != after.HideCcsImportButton { changed = append(changed, "hide_ccs_import_button") } @@ -440,6 +482,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { changed = append(changed, "openai_codex_user_agent") } + if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion { + changed = append(changed, "openai_codex_client_version") + } + if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled { + changed = append(changed, "openai_codex_version_auto_sync_enabled") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } @@ -553,6 +601,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) { changed = append(changed, service.SettingKeyDefaultPlatformQuotas) } + if !equalAccountSchedulingThresholds(before.AccountSchedulingThresholds, after.AccountSchedulingThresholds) { + changed = append(changed, service.SettingKeyAccountSchedulingThresholds) + } changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) return changed } @@ -766,6 +817,27 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 { } // equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots. +func equalAccountSchedulingThresholds(before, after map[string]int) bool { + for _, platform := range service.AllowedSchedulingThresholdPlatforms { + beforeValue := 100 + if before != nil { + if value, ok := before[platform]; ok { + beforeValue = value + } + } + afterValue := 100 + if after != nil { + if value, ok := after[platform]; ok { + afterValue = value + } + } + if beforeValue != afterValue { + return false + } + } + return true +} + func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool { for _, platform := range service.AllowedQuotaPlatforms { b := before[platform] diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index e39e664a3e..b4c94809bb 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -65,3 +65,124 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) { require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom]) } + +func TestUpdateSettingsGrokDefaultBaseURLModeIsWritable(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyGrokDefaultBaseURLMode: service.GrokDefaultBaseURLModeCLI, + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "grok_default_base_url_mode": service.GrokDefaultBaseURLModeEUWest1, + }, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, service.GrokDefaultBaseURLModeEUWest1, repo.values[service.SettingKeyGrokDefaultBaseURLMode]) +} + +func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTurnstileEnabled: "true", + service.SettingKeyTurnstileSiteKey: "site-key", + service.SettingKeyTurnstileSecretKey: "turnstile-secret", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "turnstile_enabled": true, + "turnstile_site_key": "site-key", + "turnstile_secret_key": "turnstile-secret", + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + "tencent_captcha_app_secret_key": "app-secret", + "tencent_captcha_cloud_secret_id": "cloud-secret-id", + "tencent_captcha_cloud_secret_key": "cloud-secret-key", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "cannot be enabled at the same time") +} + +func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "AppSecretKey") +} + +func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_enabled": true, + "tencent_captcha_app_id": "123456789", + "tencent_captcha_app_secret_key": "", + "tencent_captcha_cloud_secret_id": "", + "tencent_captcha_cloud_secret_key": "", + }, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey]) + require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID]) + require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey]) +} + +// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。 +// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。 +func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL, + }) + + rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, service.TencentCaptchaRegionINTL, + repo.values[service.SettingKeyTencentCaptchaRegion]) +} + +func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL, + }) + + rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, service.TencentCaptchaRegionCN, + repo.values[service.SettingKeyTencentCaptchaRegion], + "未知站点必须落回中国站,不能写入无法识别的值") +} + +func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{}) + + rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, service.TencentCaptchaRegionINTL, + repo.values[service.SettingKeyTencentCaptchaRegion]) +} + +func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) { + h, _ := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key", + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "tencent_captcha_app_id": "not-a-number", + }, nil) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "positive integer") +} diff --git a/backend/internal/handler/admin/setting_handler_platform_quota_test.go b/backend/internal/handler/admin/setting_handler_platform_quota_test.go index 273b34420b..1014b07ab9 100644 --- a/backend/internal/handler/admin/setting_handler_platform_quota_test.go +++ b/backend/internal/handler/admin/setting_handler_platform_quota_test.go @@ -64,6 +64,35 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) { } } +func TestSettingsAuditRequestDoesNotInheritStoredTencentSecrets(t *testing.T) { + req := UpdateSettingsRequest{ + TencentCaptchaAppSecretKey: " ", + TencentCaptchaCloudSecretID: "\t", + TencentCaptchaCloudSecretKey: "\n", + } + + auditReq := settingsAuditRequest(req) + req.TencentCaptchaAppSecretKey = "stored-app-secret" + req.TencentCaptchaCloudSecretID = "stored-secret-id" + req.TencentCaptchaCloudSecretKey = "stored-secret-key" + + require.Empty(t, auditReq.TencentCaptchaAppSecretKey) + require.Empty(t, auditReq.TencentCaptchaCloudSecretID) + require.Empty(t, auditReq.TencentCaptchaCloudSecretKey) +} + +func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) { + changed := diffSettings( + &service.SystemSettings{}, + &service.SystemSettings{CompactHomeEnabled: true}, + nil, + nil, + UpdateSettingsRequest{}, + ) + + require.Contains(t, changed, service.SettingKeyCompactHomeEnabled) +} + func TestEqualNullableFloat(t *testing.T) { five := 5.0 five2 := 5.0 diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 65c90a3a6b..5884dc392e 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "reflect" + "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/config" @@ -22,22 +23,23 @@ import ( // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值) - SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) - StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) - AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled *bool `json:"registration_email_domain_quota_enabled"` // 非白名单域名限量注册开关(省略=保持现值) + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值) + SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值) + StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值) + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -53,6 +55,22 @@ type UpdateSettingsRequest struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKey string `json:"turnstile_secret_key"` + // 腾讯天御验证码设置 + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"` + TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"` + TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + + // 阿里云验证码 2.0 设置 + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"` + AliyunCaptchaAccessKeySecret string `json:"aliyun_captcha_access_key_secret"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + // API Key IP 访问控制设置 APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"` @@ -142,6 +160,7 @@ type UpdateSettingsRequest struct { ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"` PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` @@ -235,6 +254,8 @@ type UpdateSettingsRequest struct { EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"` AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` + OpenAICodexClientVersion *string `json:"openai_codex_client_version"` + OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"` // codex_cli_only 加固(global-only) MinCodexVersion string `json:"min_codex_version"` @@ -307,8 +328,15 @@ type UpdateSettingsRequest struct { PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"` // Channel Monitor feature switch - ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` + ChannelMonitorMode *string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"` + + // Grok model mapping policy + GrokDefaultTextModel *string `json:"grok_default_text_model"` + GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode *string `json:"grok_default_base_url_mode"` // Available Channels feature switch (user-facing) AvailableChannelsEnabled *bool `json:"available_channels_enabled"` @@ -334,6 +362,9 @@ type UpdateSettingsRequest struct { // 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。 DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"` + // 各平台账号自动停调阈值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。 + AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds"` + // auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。 AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"` AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"` @@ -435,6 +466,14 @@ func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSe return omitted } +func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest { + req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey) + req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID) + req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey) + req.AliyunCaptchaAccessKeySecret = strings.TrimSpace(req.AliyunCaptchaAccessKeySecret) + return req +} + func (h *SettingHandler) UpdateSettings(c *gin.Context) { var sentFields map[string]json.RawMessage if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil { @@ -446,6 +485,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + auditReq := settingsAuditRequest(req) omitted := omittedSettingKeys(sentFields) previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) @@ -473,6 +513,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if req.PasskeyEnabled != nil { passkeyEnabled = *req.PasskeyEnabled } + registrationEmailDomainQuotaEnabled := previousSettings.RegistrationEmailDomainQuotaEnabled + if req.RegistrationEmailDomainQuotaEnabled != nil { + registrationEmailDomainQuotaEnabled = *req.RegistrationEmailDomainQuotaEnabled + } if passkeyEnabled { configured, _, _ := h.settingService.PasskeyConfiguration() if !configured { @@ -562,6 +606,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SMTPPassword = strings.TrimSpace(req.SMTPPassword) req.SMTPFrom = strings.TrimSpace(req.SMTPFrom) req.SMTPFromName = strings.TrimSpace(req.SMTPFromName) + req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID) + req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey) + req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID) + req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey) if req.SMTPPort <= 0 { req.SMTPPort = 587 } @@ -583,6 +631,43 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SMTPUseTLS = previousSettings.SMTPUseTLS } + turnstileEnabled := req.TurnstileEnabled + if _, sent := sentFields["turnstile_enabled"]; !sent { + turnstileEnabled = previousSettings.TurnstileEnabled + } + tencentCaptchaEnabled := req.TencentCaptchaEnabled + if _, sent := sentFields["tencent_captcha_enabled"]; !sent { + tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled + } + aliyunCaptchaEnabled := req.AliyunCaptchaEnabled + if _, sent := sentFields["aliyun_captcha_enabled"]; !sent { + aliyunCaptchaEnabled = previousSettings.AliyunCaptchaEnabled + } + enabledCaptchaProviders := 0 + for _, enabled := range []bool{turnstileEnabled, tencentCaptchaEnabled, aliyunCaptchaEnabled} { + if enabled { + enabledCaptchaProviders++ + } + } + if enabledCaptchaProviders > 1 { + response.BadRequest(c, "Multiple captcha providers (Cloudflare Turnstile / Tencent Captcha / Aliyun Captcha) cannot be enabled at the same time") + return + } + // 阿里云地域 normalize:未发送保留已存值,非法值一律按中国内地落库 + if _, sent := sentFields["aliyun_captcha_region"]; !sent { + req.AliyunCaptchaRegion = previousSettings.AliyunCaptchaRegion + } + if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP { + req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN + } + // 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库 + if _, sent := sentFields["tencent_captcha_region"]; !sent { + req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion + } + if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL { + req.TencentCaptchaRegion = service.TencentCaptchaRegionCN + } + // Turnstile 参数验证 if req.TurnstileEnabled { // 检查必填字段 @@ -610,6 +695,83 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + if tencentCaptchaEnabled { + if _, sent := sentFields["tencent_captcha_app_id"]; !sent { + req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID + } + appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64) + if err != nil || appID == 0 { + response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled") + return + } + if req.TencentCaptchaAppSecretKey == "" { + req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey + } + if req.TencentCaptchaCloudSecretID == "" { + req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID + } + if req.TencentCaptchaCloudSecretKey == "" { + req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey + } + if req.TencentCaptchaAppSecretKey == "" { + response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled") + return + } + if req.TencentCaptchaCloudSecretID == "" { + response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled") + return + } + if req.TencentCaptchaCloudSecretKey == "" { + response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled") + return + } + } + + // 阿里云验证码 2.0 参数验证 + if aliyunCaptchaEnabled { + if _, sent := sentFields["aliyun_captcha_scene_id"]; !sent { + req.AliyunCaptchaSceneID = previousSettings.AliyunCaptchaSceneID + } + if _, sent := sentFields["aliyun_captcha_prefix"]; !sent { + req.AliyunCaptchaPrefix = previousSettings.AliyunCaptchaPrefix + } + if _, sent := sentFields["aliyun_captcha_access_key_id"]; !sent { + req.AliyunCaptchaAccessKeyID = previousSettings.AliyunCaptchaAccessKeyID + } + if req.AliyunCaptchaSceneID == "" { + response.BadRequest(c, "Aliyun Captcha Scene ID is required when enabled") + return + } + if req.AliyunCaptchaPrefix == "" { + response.BadRequest(c, "Aliyun Captcha Prefix is required when enabled") + return + } + if req.AliyunCaptchaAccessKeyID == "" { + response.BadRequest(c, "Aliyun Captcha AccessKey ID is required when enabled") + return + } + // 如果未提供 AccessKey Secret,使用已保存的值(留空保留当前值) + if req.AliyunCaptchaAccessKeySecret == "" { + if previousSettings.AliyunCaptchaAccessKeySecret == "" { + response.BadRequest(c, "Aliyun Captcha AccessKey Secret is required when enabled") + return + } + req.AliyunCaptchaAccessKeySecret = previousSettings.AliyunCaptchaAccessKeySecret + } + + // 凭证任一变化时真实调用一次阿里云校验(避免配置错误导致无法登录) + credentialsChanged := previousSettings.AliyunCaptchaAccessKeyID != req.AliyunCaptchaAccessKeyID || + previousSettings.AliyunCaptchaAccessKeySecret != req.AliyunCaptchaAccessKeySecret || + previousSettings.AliyunCaptchaSceneID != req.AliyunCaptchaSceneID || + previousSettings.AliyunCaptchaRegion != req.AliyunCaptchaRegion + if credentialsChanged { + if err := h.aliyunCaptchaService.ValidateCredentials(c.Request.Context(), req.AliyunCaptchaAccessKeyID, req.AliyunCaptchaAccessKeySecret, req.AliyunCaptchaSceneID, req.AliyunCaptchaRegion); err != nil { + response.ErrorFrom(c, err) + return + } + } + } + // TOTP 双因素认证参数验证 // 只有手动配置了加密密钥才允许启用 TOTP 功能 if req.TotpEnabled && !previousSettings.TotpEnabled { @@ -1275,6 +1437,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } } + if req.OpenAICodexClientVersion != nil { + // 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。 + normalized := strings.TrimSpace(*req.OpenAICodexClientVersion) + if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" { + response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)") + return + } + req.OpenAICodexClientVersion = &normalized + } // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { @@ -1320,34 +1491,48 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { settings := &service.SystemSettings{ // 系统全局 platform quota 默认值(整体替换语义) - DefaultPlatformQuotas: req.DefaultPlatformQuotas, + DefaultPlatformQuotas: req.DefaultPlatformQuotas, + AccountSchedulingThresholds: req.AccountSchedulingThresholds, - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: req.PromoCodeEnabled, - PasswordResetEnabled: req.PasswordResetEnabled, - FrontendURL: req.FrontendURL, - InvitationCodeEnabled: req.InvitationCodeEnabled, - TotpEnabled: req.TotpEnabled, - PasskeyEnabled: passkeyEnabled, - SessionBindingEnabled: sessionBindingEnabled, - StepUpEnabled: stepUpEnabled, - AuditLogRetentionDays: req.AuditLogRetentionDays, - LoginAgreementEnabled: req.LoginAgreementEnabled, - LoginAgreementMode: loginAgreementMode, - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: registrationEmailDomainQuotaEnabled, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + PasskeyEnabled: passkeyEnabled, + SessionBindingEnabled: sessionBindingEnabled, + StepUpEnabled: stepUpEnabled, + AuditLogRetentionDays: req.AuditLogRetentionDays, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + TencentCaptchaEnabled: req.TencentCaptchaEnabled, + TencentCaptchaAppID: req.TencentCaptchaAppID, + TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey, + TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID, + TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey, + TencentCaptchaRegion: req.TencentCaptchaRegion, + AliyunCaptchaEnabled: req.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret, + AliyunCaptchaSceneID: req.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: req.AliyunCaptchaPrefix, + AliyunCaptchaRegion: req.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: func() bool { if req.APIKeyACLTrustForwardedIP != nil { return *req.APIKeyACLTrustForwardedIP @@ -1430,6 +1615,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ContactInfo: req.ContactInfo, DocURL: req.DocURL, HomeContent: req.HomeContent, + CompactHomeEnabled: req.CompactHomeEnabled, HideCcsImportButton: req.HideCcsImportButton, PurchaseSubscriptionEnabled: purchaseEnabled, PurchaseSubscriptionURL: purchaseURL, @@ -1553,6 +1739,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexUserAgent }(), + OpenAICodexClientVersion: func() string { + if req.OpenAICodexClientVersion != nil { + return *req.OpenAICodexClientVersion + } + return previousSettings.OpenAICodexClientVersion + }(), + // 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。 + OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: func() bool { + if req.OpenAICodexVersionAutoSyncEnabled != nil { + return *req.OpenAICodexVersionAutoSyncEnabled + } + return previousSettings.OpenAICodexVersionAutoSyncEnabled + }(), MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), @@ -1671,12 +1871,42 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ChannelMonitorEnabled }(), + ChannelMonitorMode: func() string { + if req.ChannelMonitorMode != nil { + return *req.ChannelMonitorMode + } + return previousSettings.ChannelMonitorMode + }(), ChannelMonitorDefaultIntervalSeconds: func() int { if req.ChannelMonitorDefaultIntervalSeconds != nil { return *req.ChannelMonitorDefaultIntervalSeconds } return previousSettings.ChannelMonitorDefaultIntervalSeconds }(), + ChannelMonitorHideThroughput: func() bool { + if req.ChannelMonitorHideThroughput != nil { + return *req.ChannelMonitorHideThroughput + } + return previousSettings.ChannelMonitorHideThroughput + }(), + GrokDefaultTextModel: func() string { + if req.GrokDefaultTextModel != nil { + return *req.GrokDefaultTextModel + } + return previousSettings.GrokDefaultTextModel + }(), + GrokCrossClientModelMapEnabled: func() bool { + if req.GrokCrossClientModelMapEnabled != nil { + return *req.GrokCrossClientModelMapEnabled + } + return previousSettings.GrokCrossClientModelMapEnabled + }(), + GrokDefaultBaseURLMode: func() string { + if req.GrokDefaultBaseURLMode != nil { + return strings.TrimSpace(*req.GrokDefaultBaseURLMode) + } + return previousSettings.GrokDefaultBaseURLMode + }(), AvailableChannelsEnabled: func() bool { if req.AvailableChannelsEnabled != nil { return *req.AvailableChannelsEnabled @@ -1842,7 +2072,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } - h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req) + h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq) // 重新获取设置返回 updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context()) @@ -1878,6 +2108,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEnabled: updatedSettings.RegistrationEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: updatedSettings.RegistrationEmailDomainQuotaEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled, FrontendURL: updatedSettings.FrontendURL, @@ -1905,6 +2136,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TurnstileEnabled: updatedSettings.TurnstileEnabled, TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled, + TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID, + TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured, + TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured, + TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured, + TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion, + AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled, + AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID, + AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured, + AliyunCaptchaSceneID: updatedSettings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: updatedSettings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: updatedSettings.AliyunCaptchaRegion, APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders, LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, @@ -1982,6 +2225,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ContactInfo: updatedSettings.ContactInfo, DocURL: updatedSettings.DocURL, HomeContent: updatedSettings.HomeContent, + CompactHomeEnabled: updatedSettings.CompactHomeEnabled, HideCcsImportButton: updatedSettings.HideCcsImportButton, PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, @@ -2024,6 +2268,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, + OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion, + OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced, + OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled, MinCodexVersion: updatedSettings.MinCodexVersion, MaxCodexVersion: updatedSettings.MaxCodexVersion, CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, @@ -2092,7 +2339,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, + ChannelMonitorMode: updatedSettings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput, + + GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, + GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, + GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, @@ -2105,6 +2358,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RiskControlEnabled: updatedSettings.RiskControlEnabled, CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, + AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds, AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, } if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index ad2bbecd22..829730c647 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) { billingType = &bt } + var upstreamModelMismatch *bool + if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } + upstreamModelMismatch = &value + } + // Parse date range var startTime, endTime *time.Time userTZ := c.Query("timezone") // Get user's timezone from request @@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) { SortOrder: c.DefaultQuery("sort_order", "desc"), } filters := usagestats.UsageLogFilters{ - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - RequestID: requestID, - Model: model, - ModelFilterSource: usagestats.ModelSourceRequested, - RequestType: requestType, - Stream: stream, - BillingType: billingType, - BillingMode: billingMode, - StartTime: startTime, - EndTime: endTime, - ExactTotal: exactTotal, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + RequestID: requestID, + Model: model, + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + BillingMode: billingMode, + UpstreamModelMismatch: upstreamModelMismatch, + StartTime: startTime, + EndTime: endTime, + ExactTotal: exactTotal, } records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters) @@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) { billingType = &bt } + var upstreamModelMismatch *bool + if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") + return + } + upstreamModelMismatch = &value + } + // Parse date range userTZ := c.Query("timezone") now := timezone.NowInUserLocation(userTZ) @@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) { // Build filters and call GetStatsWithFilters filters := usagestats.UsageLogFilters{ - UserID: userID, - APIKeyID: apiKeyID, - AccountID: accountID, - GroupID: groupID, - Model: model, - ModelFilterSource: usagestats.ModelSourceRequested, - RequestType: requestType, - Stream: stream, - BillingType: billingType, - BillingMode: billingMode, - StartTime: &startTime, - EndTime: &endTime, + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + BillingType: billingType, + BillingMode: billingMode, + UpstreamModelMismatch: upstreamModelMismatch, + StartTime: &startTime, + EndTime: &endTime, } var stats *usagestats.UsageStats diff --git a/backend/internal/handler/admin/usage_query_cache.go b/backend/internal/handler/admin/usage_query_cache.go index b288a95ba4..f6b7746e1b 100644 --- a/backend/internal/handler/admin/usage_query_cache.go +++ b/backend/internal/handler/admin/usage_query_cache.go @@ -11,17 +11,18 @@ import ( var usageStatsCache = newSnapshotCache(30 * time.Second) type usageStatsCacheKeyData struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - UserID int64 `json:"user_id"` - APIKeyID int64 `json:"api_key_id"` - AccountID int64 `json:"account_id"` - GroupID int64 `json:"group_id"` - Model string `json:"model"` - BillingMode string `json:"billing_mode"` - RequestType *int16 `json:"request_type"` - Stream *bool `json:"stream"` - BillingType *int8 `json:"billing_type"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + UserID int64 `json:"user_id"` + APIKeyID int64 `json:"api_key_id"` + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Model string `json:"model"` + BillingMode string `json:"billing_mode"` + RequestType *int16 `json:"request_type"` + Stream *bool `json:"stream"` + BillingType *int8 `json:"billing_type"` + UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } func usageStatsCacheKey(filters usagestats.UsageLogFilters) string { @@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string { end = filters.EndTime.UTC().Format(time.RFC3339) } return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{ - StartTime: start, - EndTime: end, - UserID: filters.UserID, - APIKeyID: filters.APIKeyID, - AccountID: filters.AccountID, - GroupID: filters.GroupID, - Model: filters.Model, - BillingMode: filters.BillingMode, - RequestType: filters.RequestType, - Stream: filters.Stream, - BillingType: filters.BillingType, + StartTime: start, + EndTime: end, + UserID: filters.UserID, + APIKeyID: filters.APIKeyID, + AccountID: filters.AccountID, + GroupID: filters.GroupID, + Model: filters.Model, + BillingMode: filters.BillingMode, + RequestType: filters.RequestType, + Stream: filters.Stream, + BillingType: filters.BillingType, + UpstreamModelMismatch: filters.UpstreamModelMismatch, }) } diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 3363230815..6361b97dd6 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -3,6 +3,8 @@ package handler import ( "context" + "errors" + "math" "strconv" "strings" "time" @@ -62,6 +64,43 @@ type UpdateAPIKeyRequest struct { ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // 重置限速用量 } +func validAPIKeyLimit(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 } + +func validateAPIKeyCreateRequest(req CreateAPIKeyRequest) error { + if req.Quota != nil && !validAPIKeyLimit(*req.Quota) { + return errors.New("invalid quota") + } + if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) { + return errors.New("invalid rate_limit_5h") + } + if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) { + return errors.New("invalid rate_limit_1d") + } + if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) { + return errors.New("invalid rate_limit_7d") + } + if req.ExpiresInDays != nil && *req.ExpiresInDays <= 0 { + return errors.New("invalid expires_in_days") + } + return nil +} + +func validateAPIKeyUpdateRequest(req UpdateAPIKeyRequest) error { + if req.Quota != nil && !validAPIKeyLimit(*req.Quota) { + return errors.New("invalid quota") + } + if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) { + return errors.New("invalid rate_limit_5h") + } + if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) { + return errors.New("invalid rate_limit_1d") + } + if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) { + return errors.New("invalid rate_limit_7d") + } + return nil +} + // List handles listing user's API keys with pagination // GET /api/v1/api-keys func (h *APIKeyHandler) List(c *gin.Context) { @@ -152,6 +191,10 @@ func (h *APIKeyHandler) Create(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if err := validateAPIKeyCreateRequest(req); err != nil { + response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative, and expires_in_days must be greater than zero") + return + } svcReq := service.CreateAPIKeyRequest{ Name: req.Name, @@ -203,6 +246,10 @@ func (h *APIKeyHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if err := validateAPIKeyUpdateRequest(req); err != nil { + response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative") + return + } svcReq := service.UpdateAPIKeyRequest{ IPWhitelist: req.IPWhitelist, diff --git a/backend/internal/handler/api_key_handler_validation_test.go b/backend/internal/handler/api_key_handler_validation_test.go new file mode 100644 index 0000000000..9a55e3535f --- /dev/null +++ b/backend/internal/handler/api_key_handler_validation_test.go @@ -0,0 +1,43 @@ +//go:build unit + +package handler + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateAPIKeyCreateRequest(t *testing.T) { + zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(1) + positiveDays, zeroDays, negativeDays := 1, 0, -1 + require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{})) + require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{Quota: &zero, RateLimit5h: &large, ExpiresInDays: &positiveDays})) + + for _, req := range []CreateAPIKeyRequest{ + {Quota: &negative}, + {Quota: &nan}, + {RateLimit5h: &inf}, + {RateLimit1d: &negative}, + {RateLimit7d: &negative}, + {ExpiresInDays: &zeroDays}, + {ExpiresInDays: &negativeDays}, + } { + require.Error(t, validateAPIKeyCreateRequest(req)) + } +} + +func TestValidateAPIKeyUpdateRequest(t *testing.T) { + zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(-1) + require.NoError(t, validateAPIKeyUpdateRequest(UpdateAPIKeyRequest{Quota: &zero, RateLimit7d: &large})) + + for _, req := range []UpdateAPIKeyRequest{ + {Quota: &negative}, + {RateLimit5h: &nan}, + {RateLimit1d: &inf}, + {RateLimit7d: &negative}, + } { + require.Error(t, validateAPIKeyUpdateRequest(req)) + } +} diff --git a/backend/internal/handler/auth_captcha_request_test.go b/backend/internal/handler/auth_captcha_request_test.go new file mode 100644 index 0000000000..d8c5458ba3 --- /dev/null +++ b/backend/internal/handler/auth_captcha_request_test.go @@ -0,0 +1,93 @@ +//go:build unit + +package handler + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) { + const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}` + + tests := []struct { + name string + decode func([]byte) service.CaptchaProof + }{ + { + name: "登录", + decode: func(raw []byte) service.CaptchaProof { + var req LoginRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "注册", + decode: func(raw []byte) service.CaptchaProof { + var req RegisterRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "发送邮箱验证码", + decode: func(raw []byte) service.CaptchaProof { + var req SendVerifyCodeRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "忘记密码", + decode: func(raw []byte) service.CaptchaProof { + var req ForgotPasswordRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "OAuth启动", + decode: func(raw []byte) service.CaptchaProof { + var req oauthStartCaptchaRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "Passkey登录", + decode: func(raw []byte) service.CaptchaProof { + var req passkeyBeginLoginRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "OAuth待处理账号发送邮箱验证码", + decode: func(raw []byte) service.CaptchaProof { + var req sendPendingOAuthVerifyCodeRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + { + name: "OAuth待处理账号创建", + decode: func(raw []byte) service.CaptchaProof { + var req createPendingOAuthAccountRequest + require.NoError(t, json.Unmarshal(raw, &req)) + return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + proof := test.decode([]byte(payload)) + require.Equal(t, "ticket-value", proof.TencentTicket) + require.Equal(t, "@rand-value", proof.TencentRandstr) + }) + } +} diff --git a/backend/internal/handler/auth_dingtalk_oauth.go b/backend/internal/handler/auth_dingtalk_oauth.go index 25c04ede79..067f27f694 100644 --- a/backend/internal/handler/auth_dingtalk_oauth.go +++ b/backend/internal/handler/auth_dingtalk_oauth.go @@ -113,6 +113,9 @@ func clearDingTalkCookie(c *gin.Context, name string, secure bool) { // DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。 // GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getDingTalkOAuthConfig(c.Request.Context()) if err != nil { frontendCB := dingTalkOAuthDefaultFrontendCB @@ -165,7 +168,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // ─── buildDingTalkAuthorizeURL ───────────────────────────────────────────── diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go index 09567b1b3d..8717485ecf 100644 --- a/backend/internal/handler/auth_email_oauth.go +++ b/backend/internal/handler/auth_email_oauth.go @@ -59,6 +59,9 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) { } func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider) if err != nil { response.ErrorFrom(c, err) @@ -90,7 +93,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7b38ee130e..2e267877f9 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -48,19 +48,23 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe // RegisterRequest represents the registration request payload type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - VerifyCode string `json:"verify_code"` - TurnstileToken string `json:"turnstile_token"` - PromoCode string `json:"promo_code"` // 注册优惠码 - InvitationCode string `json:"invitation_code"` // 邀请码 - AffCode string `json:"aff_code"` // 邀请返利码 + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + VerifyCode string `json:"verify_code"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` + PromoCode string `json:"promo_code"` // 注册优惠码 + InvitationCode string `json:"invitation_code"` // 邀请码 + AffCode string `json:"aff_code"` // 邀请返利码 } // SendVerifyCodeRequest 发送验证码请求 type SendVerifyCodeRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } // SendVerifyCodeResponse 发送验证码响应 @@ -71,9 +75,19 @@ type SendVerifyCodeResponse struct { // LoginRequest represents the login request payload type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + +func captchaProof(turnstileToken, tencentTicket, tencentRandstr string) service.CaptchaProof { + return service.CaptchaProof{ + TurnstileToken: turnstileToken, + TencentTicket: tencentTicket, + TencentRandstr: tencentRandstr, + } } // AuthResponse 认证响应格式(匹配前端期望) @@ -169,8 +183,9 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - // Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token) - if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil { + // 验证当前启用的验证码(邮箱验证码注册场景避免重复校验一次性票据) + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptchaForRegister(c.Request.Context(), proof, ip.GetClientIP(c), req.VerifyCode); err != nil { response.ErrorFrom(c, err) return } @@ -201,8 +216,8 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -228,8 +243,8 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -573,8 +588,10 @@ func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) { // ForgotPasswordRequest 忘记密码请求 type ForgotPasswordRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` } // ForgotPasswordResponse 忘记密码响应 @@ -591,8 +608,8 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { return } - // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 92720495b9..8a5b9ae0ca 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -82,6 +82,9 @@ func (e *linuxDoTokenExchangeError) Error() string { // LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。 // GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -147,7 +150,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。 diff --git a/backend/internal/handler/auth_oauth_captcha_start.go b/backend/internal/handler/auth_oauth_captcha_start.go new file mode 100644 index 0000000000..e8c174e7d8 --- /dev/null +++ b/backend/internal/handler/auth_oauth_captcha_start.go @@ -0,0 +1,50 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type oauthStartCaptchaRequest struct { + // TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名) + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + +type oauthStartResponse struct { + AuthorizeURL string `json:"authorize_url"` +} + +func (h *AuthHandler) requireActionCaptchaForOAuthLoginStart(c *gin.Context) bool { + if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") { + return true + } + + var req oauthStartCaptchaRequest + if c.Request.Method == http.MethodPost { + _ = c.ShouldBindJSON(&req) + } + if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TurnstileToken: req.TurnstileToken, + TencentTicket: req.TencentCaptchaTicket, + TencentRandstr: req.TencentCaptchaRandstr, + }, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return false + } + return true +} + +func respondOAuthStart(c *gin.Context, authorizeURL string) { + if c.Request.Method == http.MethodPost { + response.Success(c, oauthStartResponse{AuthorizeURL: authorizeURL}) + return + } + c.Redirect(http.StatusFound, authorizeURL) +} diff --git a/backend/internal/handler/auth_oauth_captcha_start_test.go b/backend/internal/handler/auth_oauth_captcha_start_test.go new file mode 100644 index 0000000000..d6e9e239c1 --- /dev/null +++ b/backend/internal/handler/auth_oauth_captcha_start_test.go @@ -0,0 +1,178 @@ +//go:build unit + +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type oauthCaptchaSettingRepo struct { + values map[string]string +} + +func (r *oauthCaptchaSettingRepo) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} +func (r *oauthCaptchaSettingRepo) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", service.ErrSettingNotFound + } + return value, nil +} +func (r *oauthCaptchaSettingRepo) Set(context.Context, string, string) error { return nil } +func (r *oauthCaptchaSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + 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 *oauthCaptchaSettingRepo) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *oauthCaptchaSettingRepo) GetAll(context.Context) (map[string]string, error) { + return r.values, nil +} +func (r *oauthCaptchaSettingRepo) Delete(context.Context, string) error { return nil } + +type oauthCaptchaVerifier struct { + calls int + proof service.TencentCaptchaProof +} + +func (v *oauthCaptchaVerifier) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) { + v.calls++ + v.proof = proof + return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil +} + +func newOAuthCaptchaTestHandler(enabled bool) (*AuthHandler, *oauthCaptchaVerifier) { + values := map[string]string{} + if enabled { + values = map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + } + } + cfg := &config.Config{} + settings := service.NewSettingService(&oauthCaptchaSettingRepo{values: values}, cfg) + verifier := &oauthCaptchaVerifier{} + authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil) + authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier)) + return &AuthHandler{authService: authService, settingSvc: settings, cfg: cfg}, verifier +} + +func oauthStartHandlers() map[string]func(*AuthHandler, *gin.Context) { + return map[string]func(*AuthHandler, *gin.Context){ + "github": func(h *AuthHandler, c *gin.Context) { h.GitHubOAuthStart(c) }, + "google": func(h *AuthHandler, c *gin.Context) { h.GoogleOAuthStart(c) }, + "linuxdo": func(h *AuthHandler, c *gin.Context) { h.LinuxDoOAuthStart(c) }, + "dingtalk": func(h *AuthHandler, c *gin.Context) { h.DingTalkOAuthStart(c) }, + "wechat": func(h *AuthHandler, c *gin.Context) { h.WeChatOAuthStart(c) }, + "oidc": func(h *AuthHandler, c *gin.Context) { h.OIDCOAuthStart(c) }, + } +} + +func TestOAuthStartGetRejectsAnonymousLoginWhenTencentEnabledWithoutSideEffects(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider, start := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start?intent=bind_current_user", nil) + + start(handler, c) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Empty(t, recorder.Header().Get("Location")) + require.Empty(t, recorder.Header().Values("Set-Cookie")) + require.Zero(t, verifier.calls) + }) + } +} + +func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/oauth/"+provider+"/start", + strings.NewReader(`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + + require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) + respondOAuthStart(c, "https://provider.example/authorize") + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"authorize_url":"https://provider.example/authorize"`) + require.Equal(t, 1, verifier.calls) + require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof) + }) + } +} + +func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) { + gin.SetMode(gin.TestMode) + for provider := range oauthStartHandlers() { + t.Run(provider, func(t *testing.T) { + handler, verifier := newOAuthCaptchaTestHandler(true) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/"+provider+"/start", strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + require.False(t, handler.requireActionCaptchaForOAuthLoginStart(c)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Zero(t, verifier.calls) + }) + } +} + +func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &AuthHandler{} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil) + + require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier := newOAuthCaptchaTestHandler(false) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil) + + require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c)) + respondOAuthStart(c, "https://provider.example/authorize") + + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "https://provider.example/authorize", recorder.Header().Get("Location")) + require.Zero(t, verifier.calls) +} diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 79e9f0a80b..7b1d71dca4 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -66,20 +66,25 @@ type bindPendingOAuthLoginRequest struct { } type createPendingOAuthAccountRequest struct { - Email string `json:"email" binding:"required,email"` - VerifyCode string `json:"verify_code,omitempty"` - Password string `json:"password" binding:"required,min=6"` - InvitationCode string `json:"invitation_code,omitempty"` - AffCode string `json:"aff_code,omitempty"` - AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` - AdoptAvatar *bool `json:"adopt_avatar,omitempty"` + Email string `json:"email" binding:"required,email"` + VerifyCode string `json:"verify_code,omitempty"` + Password string `json:"password" binding:"required,min=6"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` + InvitationCode string `json:"invitation_code,omitempty"` + AffCode string `json:"aff_code,omitempty"` + AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` + AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } type sendPendingOAuthVerifyCodeRequest struct { - Email string `json:"email" binding:"required,email"` - TurnstileToken string `json:"turnstile_token,omitempty"` - PendingAuthToken string `json:"pending_auth_token,omitempty"` - PendingOAuthToken string `json:"pending_oauth_token,omitempty"` + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token,omitempty"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"` + PendingAuthToken string `json:"pending_auth_token,omitempty"` + PendingOAuthToken string `json:"pending_oauth_token,omitempty"` } func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest { @@ -564,7 +569,8 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) { return } - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -1754,6 +1760,11 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) response.ErrorFrom(c, err) return } + proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr) + if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } tokenPair, user, err := h.authService.RegisterOAuthEmailAccount( c.Request.Context(), @@ -1987,6 +1998,21 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { response.Success(c, payload) return } + // ─── 安全修复(账号接管 0day)──────────────────────────────────────────── + // 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自 + // 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode + // 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、 + // 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的 + // applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID, + // 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。 + // 只有两类 session 允许在此处执行 adoption/binding: + // 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户; + // 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。 + // 其余状态一律只返回 payload,不绑定、不消费 session。 + if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) { + response.Success(c, payload) + return + } if !adoptionDecision.hasDecision() { adoptionRequired, _ := payload["adoption_required"].(bool) if adoptionRequired { diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 4da8db2ab6..91a65d5f11 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -910,6 +910,92 @@ func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) { require.Nil(t, storedSession.ConsumedAt) } +func TestExchangePendingOAuthCompletionChoiceStateDoesNotBindIdentity(t *testing.T) { + // 回归测试:复刻"补邮箱/创建账户"路径的账号接管 0day。 + // 攻击者用自己的 OAuth 账号登录后,在 create-account 步骤提交受害者邮箱, + // 后端发现邮箱已存在会把 pending session 转入 choice 状态并指向受害者 + // (TargetUserID=受害者、无密码/验证码证明)。此时带 adoption decision 调 + // exchange 绝不能把 OAuth identity 绑定到受害者账号。 + handler, client := newOAuthPendingFlowTestHandler(t, false) + ctx := context.Background() + + victim, err := client.User.Create(). + SetEmail("victim@example.com"). + SetUsername("victim-user"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("choice-state-attack-session-token"). + SetIntent("login"). + SetProviderType("linuxdo"). + SetProviderKey("linuxdo"). + SetProviderSubject("attacker-subject-123"). + SetTargetUserID(victim.ID). + SetResolvedEmail(victim.Email). + SetBrowserSessionKey("choice-state-attack-browser-session-key"). + SetUpstreamIdentityClaims(map[string]any{ + "username": "attacker_linuxdo_user", + "suggested_display_name": "Attacker Display Name", + "suggested_avatar_url": "https://cdn.example/attacker.png", + }). + SetLocalFlowState(map[string]any{ + oauthCompletionResponseKey: map[string]any{ + "step": oauthPendingChoiceStep, + "adoption_required": true, + "force_email_on_signup": true, + "email_binding_required": true, + "existing_account_bindable": true, + "email": victim.Email, + "resolved_email": victim.Email, + "redirect": "/dashboard", + }, + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-state-attack-browser-session-key")}) + ginCtx.Request = req + + handler.ExchangePendingOAuthCompletion(ginCtx) + + require.Equal(t, http.StatusOK, recorder.Code) + data := decodeJSONResponseData(t, recorder) + require.NotContains(t, data, "access_token") + require.Equal(t, oauthPendingChoiceStep, data["step"]) + + // 攻击者的 OAuth identity 绝不能绑定到受害者账号 + identityCount, err := client.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ("linuxdo"), + authidentity.ProviderKeyEQ("linuxdo"), + authidentity.ProviderSubjectEQ("attacker-subject-123"), + ). + Count(ctx) + require.NoError(t, err) + require.Zero(t, identityCount) + + // 受害者资料不得被 adoption 篡改 + storedVictim, err := client.User.Get(ctx, victim.ID) + require.NoError(t, err) + require.Equal(t, "victim-user", storedVictim.Username) + + // session 不得被消费(攻击者无法进入下一环) + storedSession, err := client.PendingAuthSession.Get(ctx, session.ID) + require.NoError(t, err) + require.Nil(t, storedSession.ConsumedAt) +} + func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) { payload := normalizePendingOAuthCompletionResponse(map[string]any{ "access_token": "legacy-access-token", @@ -1374,7 +1460,7 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te require.Equal(t, "owner@example.com", storedSession.ResolvedEmail) } -func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) { +func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitelist(t *testing.T) { handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ emailVerifyEnabled: true, emailCache: &oauthPendingFlowEmailCacheStub{ @@ -1387,10 +1473,19 @@ func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t }, }, settingValues: map[string]string{ - service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + service.SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, }) ctx := context.Background() + _, err := client.User.Create(). + SetEmail("existing@gmail.com"). + SetUsername("existing-gmail-user"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) session, err := client.PendingAuthSession.Create(). SetSessionToken("suffix-whitelist-session-token"). @@ -1417,6 +1512,60 @@ func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t handler.CreateOIDCOAuthAccount(ginCtx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + payload := decodeJSONBody(t, recorder) + require.Equal(t, "EMAIL_DOMAIN_REGISTRATION_LIMIT", payload["reason"]) + + count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, count) +} + +// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义, +// 即使该域名下还没有任何账户也不放行。 +func TestCreateOIDCOAuthAccountRejectsEmailOutsideWhitelistWhenQuotaDisabled(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + emailVerifyEnabled: true, + emailCache: &oauthPendingFlowEmailCacheStub{ + verificationCodes: map[string]*service.VerificationCodeData{ + "foo@gmail.com": { + Code: "135790", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + }, + }, + settingValues: map[string]string{ + service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`, + }, + }) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("suffix-strict-session-token"). + SetIntent("login"). + SetProviderType("oidc"). + SetProviderKey("https://issuer.example"). + SetProviderSubject("oidc-suffix-strict-123"). + SetBrowserSessionKey("suffix-strict-browser-session-key"). + SetUpstreamIdentityClaims(map[string]any{ + "username": "oidc_user", + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`) + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-strict-browser-session-key")}) + ginCtx.Request = req + + handler.CreateOIDCOAuthAccount(ginCtx) + require.Equal(t, http.StatusBadRequest, recorder.Code) payload := decodeJSONBody(t, recorder) require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"]) @@ -2947,6 +3096,8 @@ type oauthPendingFlowUserRepo struct { options oauthPendingFlowUserRepoOptions } +var _ service.RegistrationEmailDomainRepository = (*oauthPendingFlowUserRepo)(nil) + type oauthPendingFlowUserRepoOptions struct { rejectDeleteWhileAuthIdentityExists bool } @@ -2989,6 +3140,35 @@ func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context return r.Create(ctx, user) } +func (r *oauthPendingFlowUserRepo) CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) { + domain = service.NormalizeRegistrationEmailDomain(domain) + if domain == "" { + return 0, nil + } + emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx) + if err != nil { + return 0, err + } + count := 0 + for _, email := range emails { + if service.RegistrationEmailDomain(email) == domain { + count++ + } + } + return count, nil +} + +func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *service.User, domain string) error { + count, err := r.CountUsersByEmailDomain(ctx, domain) + if err != nil { + return err + } + if count > 0 { + return service.ErrEmailDomainRegistrationLimit + } + return r.CreateWithEmailAliasGuard(ctx, user) +} + func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { entity, err := r.client.User.Get(ctx, id) if err != nil { diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 904bdc2250..0367e4cff0 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -115,6 +115,9 @@ type oidcJWK struct { // OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。 // GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getOIDCOAuthConfig(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -190,7 +193,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。 diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 3e8a21dbab..432fab5206 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -96,6 +96,9 @@ type wechatPaymentOAuthContext struct { // WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived // browser cookies required by the rebuild pending-auth bridge. func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { + if !h.requireActionCaptchaForOAuthLoginStart(c) { + return + } cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c) if err != nil { response.ErrorFrom(c, err) @@ -145,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { return } - c.Redirect(http.StatusFound, authURL) + respondOAuthStart(c, authURL) } // WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid, diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 05d64e2002..300eb1b1a7 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -12,12 +12,11 @@ import ( // AvailableChannelHandler 处理用户侧「可用渠道」查询。 // -// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤: +// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤: // 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道; // 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些; -// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型, -// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问 -// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏; +// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台 +// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力; // 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels // / 内部 ID / Status 等管理字段)。 type AvailableChannelHandler struct { @@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { } // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: -// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。 +// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。 +// +// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的 +// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道 +// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。 // 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。 func buildPlatformSections( ch service.AvailableChannel, visibleGroups []userAvailableGroup, ) []userChannelPlatformSection { groupsByPlatform := make(map[string][]userAvailableGroup, 4) + compositeGroups := make([]userAvailableGroup, 0, 1) for _, g := range visibleGroups { if g.Platform == "" { continue } + if g.Platform == service.PlatformComposite { + compositeGroups = append(compositeGroups, g) + continue + } groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g) } + + if len(compositeGroups) > 0 { + modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels)) + for i := range ch.SupportedModels { + if platform := ch.SupportedModels[i].Platform; platform != "" { + modelPlatforms[platform] = struct{}{} + } + } + if len(modelPlatforms) == 0 { + groupsByPlatform[service.PlatformComposite] = append( + groupsByPlatform[service.PlatformComposite], + compositeGroups..., + ) + } else { + for platform := range modelPlatforms { + groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...) + } + } + } if len(groupsByPlatform) == 0 { return nil } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index bb53297980..24d7d1cb45 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { require.Len(t, sections[0].SupportedModels, 1) require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) } + +func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2.5e-6 + ch := service.AvailableChannel{ + Name: "composite-channel", + SupportedModels: []service.SupportedModel{ + { + Name: "claude-sonnet-4-6", + Platform: service.PlatformAnthropic, + Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice}, + }, + { + Name: "gpt-5", + Platform: service.PlatformOpenAI, + Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice}, + }, + }, + } + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + for _, section := range sections { + require.Len(t, section.Groups, 1) + require.Equal(t, int64(9), section.Groups[0].ID) + require.Equal(t, service.PlatformComposite, section.Groups[0].Platform) + require.Len(t, section.SupportedModels, 1) + require.Equal(t, section.Platform, section.SupportedModels[0].Platform) + require.NotNil(t, section.SupportedModels[0].Pricing) + } + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) + require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Len(t, sections[0].SupportedModels, 1) + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, []int64{1, 9}, []int64{ + sections[0].Groups[0].ID, + sections[0].Groups[1].ID, + }) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + require.Len(t, sections[1].Groups, 1) + require.Equal(t, int64(9), sections[1].Groups[0].ID) +} + +func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) { + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(service.AvailableChannel{ + SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}}, + }, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformComposite, sections[0].Platform) + require.Len(t, sections[0].Groups, 1) + require.Empty(t, sections[0].SupportedModels) +} diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index cc36b33461..7e42c5a74d 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -35,7 +35,8 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool { if h.settingService == nil { return true } - return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled + runtime := h.settingService.GetChannelMonitorRuntime(c.Request.Context()) + return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1 } // --- Response --- diff --git a/backend/internal/handler/channel_monitor_v2_handler.go b/backend/internal/handler/channel_monitor_v2_handler.go new file mode 100644 index 0000000000..ea661d1a76 --- /dev/null +++ b/backend/internal/handler/channel_monitor_v2_handler.go @@ -0,0 +1,204 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "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" +) + +type ChannelMonitorV2Handler struct { + service *service.ChannelMonitorV2Service +} + +func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler { + return &ChannelMonitorV2Handler{service: svc} +} + +// channelMonitorV2IsAdmin is true when the request already passed admin auth +// (shared Dimensions/Errors handlers serve both user and admin route groups). +func channelMonitorV2IsAdmin(c *gin.Context) bool { + role, ok := middleware.GetUserRoleFromContext(c) + return ok && role == service.RoleAdmin +} + +func (h *ChannelMonitorV2Handler) GetConfig(c *gin.Context) { + cfg, err := h.service.GetConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, cfg) +} + +func (h *ChannelMonitorV2Handler) UpdateConfig(c *gin.Context) { + var input service.ChannelMonitorV2Config + if err := c.ShouldBindJSON(&input); err != nil { + response.BadRequest(c, "invalid channel monitor v2 config") + return + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + response.Unauthorized(c, "user not found in context") + return + } + updated, err := h.service.UpdateConfig(c.Request.Context(), input, input.Version, subject.UserID) + if err != nil { + if errors.Is(err, service.ErrChannelMonitorV2ConfigConflict) { + response.Error(c, http.StatusConflict, err.Error()) + return + } + if errors.Is(err, service.ErrChannelMonitorV2InvalidConfig) { + response.BadRequest(c, err.Error()) + return + } + response.ErrorFrom(c, err) + return + } + response.Success(c, updated) +} + +func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + result, err := h.service.Dimensions(c.Request.Context(), filter) + if err != nil { + response.ErrorFrom(c, err) + return + } + // Admin and user share this handler; only non-admin responses strip volume. + if !channelMonitorV2IsAdmin(c) { + service.RedactChannelMonitorV2Dimensions(result) + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) Snapshot(c *gin.Context) { h.snapshot(c, false) } +func (h *ChannelMonitorV2Handler) AdminSnapshot(c *gin.Context) { h.snapshot(c, true) } +func (h *ChannelMonitorV2Handler) Models(c *gin.Context) { h.models(c, false) } +func (h *ChannelMonitorV2Handler) AdminModels(c *gin.Context) { h.models(c, true) } +func (h *ChannelMonitorV2Handler) Matrix(c *gin.Context) { h.matrix(c, false) } +func (h *ChannelMonitorV2Handler) AdminMatrix(c *gin.Context) { h.matrix(c, true) } +func (h *ChannelMonitorV2Handler) Users(c *gin.Context) { h.users(c, false) } +func (h *ChannelMonitorV2Handler) AdminUsers(c *gin.Context) { h.users(c, true) } + +func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + result, err := h.service.Snapshot(c.Request.Context(), filter, admin) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + result, err := h.service.Models(c.Request.Context(), filter, admin) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + groupBy, err := service.ParseChannelMonitorV2GroupBy(c.Query("group_by")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c)) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) { + filter, ok := h.parseFilter(c) + if !ok { + return + } + subject, exists := middleware.GetAuthSubjectFromContext(c) + if !exists { + response.Error(c, http.StatusUnauthorized, "user not found in context") + return + } + result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) { + groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id")) + if err != nil { + response.BadRequest(c, "invalid group_id") + return service.ChannelMonitorV2Filter{}, false + } + filter, err := h.service.ParseFilter(c.Query("range"), queryList(c, "platform"), queryList(c, "model"), groups) + if err != nil { + response.BadRequest(c, err.Error()) + return service.ChannelMonitorV2Filter{}, false + } + return filter, true +} + +func queryList(c *gin.Context, key string) []string { + values := c.QueryArray(key) + result := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + } + return result +} + +func parseChannelMonitorV2GroupIDs(values []string) ([]int64, error) { + result := make([]int64, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 { + return nil, errors.New("invalid group id") + } + result = append(result, id) + } + return result, nil +} diff --git a/backend/internal/handler/channel_monitor_v2_handler_test.go b/backend/internal/handler/channel_monitor_v2_handler_test.go new file mode 100644 index 0000000000..b61c582f4e --- /dev/null +++ b/backend/internal/handler/channel_monitor_v2_handler_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil) + require.Equal(t, []string{"openai", "grok", "anthropic"}, queryList(c, "platform")) +} + +func TestChannelMonitorV2GroupByQueryDefaultsAndRejectsInvalid(t *testing.T) { + groupBy, err := service.ParseChannelMonitorV2GroupBy("") + require.NoError(t, err) + require.Equal(t, service.ChannelMonitorV2GroupByPlatformGroup, groupBy) + _, err = service.ParseChannelMonitorV2GroupBy("invalid") + require.Error(t, err) +} + +func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil) + h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil)) + h.Matrix(c) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index bb001a8b22..0d57c709c6 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -1,9 +1,12 @@ package handler import ( + "strings" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" ) func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) { @@ -55,3 +58,42 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string { } return apiKey.Group.Platform } + +func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) { + if apiKey == nil || apiKey.Group == nil { + return "", nil, false + } + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + return "", nil, false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { + return "", nil, false + } + return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true +} + +func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) { + maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return body, false + } + return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings) +} + +func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { + if c == nil || c.Request == nil { + return + } + // The Messages bridge synthesizes a default OpenAI effort when + // output_config.effort is omitted. Bind the group policy only for an + // explicit client value so the ceiling does not alter that default. + effort := gjson.GetBytes(body, "output_config.effort") + if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" { + return + } + maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return + } + c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings)) +} diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index a879f9ffe5..2fa79c538b 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -77,6 +77,46 @@ func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t * require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick")) } +func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { + gin.SetMode(gin.TestMode) + group := &service.Group{ + Platform: service.PlatformComposite, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + } + apiKey := &service.APIKey{Group: group} + body := []byte(`{"reasoning":{"effort":"max"}}`) + + openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI)) + got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body) + require.True(t, changed) + require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got)) + + bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`)) + bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body) + require.True(t, changed) + require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound)) + + omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil) + omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI)) + bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`)) + omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body) + require.False(t, changed) + require.Equal(t, body, omitted) + + grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok)) + got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body) + require.False(t, changed) + require.Equal(t, body, got) +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/dto/group_mapper_profit_visibility_test.go b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go new file mode 100644 index 0000000000..a3a9c5a83f --- /dev/null +++ b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go @@ -0,0 +1,75 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// profitControlJSONFields 是分组利润控制的三个 JSON 字段。它们与同响应中的 +// rate_multiplier 相乘即可反推出运营方的上游采购成本上限,属于内部经营信息, +// 只能出现在管理员 DTO 中。 +var profitControlJSONFields = []string{ + "profit_control_enabled", + "profit_min_margin", + "profit_safety_buffer", +} + +func profitControlServiceGroup() *service.Group { + return &service.Group{ + ID: 7, + Name: "profit-gated", + Platform: service.PlatformAnthropic, + RateMultiplier: 2.0, + Status: service.StatusActive, + ProfitControlEnabled: true, + ProfitMinMargin: 0.3, + ProfitSafetyBuffer: 0.05, + } +} + +func marshalToMap(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// TestGroupFromServiceOmitsProfitControl 钉死普通用户侧的分组 DTO 不泄露利润控制配置。 +func TestGroupFromServiceOmitsProfitControl(t *testing.T) { + for name, got := range map[string]any{ + "GroupFromService": GroupFromService(profitControlServiceGroup()), + "GroupFromServiceShallow": GroupFromServiceShallow(profitControlServiceGroup()), + } { + fields := marshalToMap(t, got) + for _, f := range profitControlJSONFields { + if _, ok := fields[f]; ok { + t.Errorf("%s: 普通用户 DTO 不得包含 %q", name, f) + } + } + if _, ok := fields["rate_multiplier"]; !ok { + t.Errorf("%s: 应仍返回 rate_multiplier", name) + } + } +} + +// TestGroupFromServiceAdminIncludesProfitControl 钉死管理端仍能读写利润控制配置。 +func TestGroupFromServiceAdminIncludesProfitControl(t *testing.T) { + admin := GroupFromServiceAdmin(profitControlServiceGroup()) + if admin.ProfitControlEnabled != true || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 { + t.Fatalf("管理员 DTO 未透传利润控制配置: %+v", admin) + } + fields := marshalToMap(t, admin) + for _, f := range profitControlJSONFields { + if _, ok := fields[f]; !ok { + t.Errorf("管理员 DTO 应包含 %q", f) + } + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 04c811cedf..0c7d892297 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -146,6 +146,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { } out := &AdminGroup{ Group: groupFromServiceBase(g), + ProfitControlEnabled: g.ProfitControlEnabled, + ProfitMinMargin: g.ProfitMinMargin, + ProfitSafetyBuffer: g.ProfitSafetyBuffer, ModelRouting: g.ModelRouting, ModelRoutingEnabled: g.ModelRoutingEnabled, MCPXMLInject: g.MCPXMLInject, @@ -199,7 +202,12 @@ func groupFromServiceBase(g *service.Group) Group { VideoPrice480P: g.VideoPrice480P, VideoPrice720P: g.VideoPrice720P, VideoPrice1080P: g.VideoPrice1080P, + VideoModelPrices: g.VideoModelPrices, WebSearchPricePerCall: g.WebSearchPricePerCall, + SearchPricePer1k: g.SearchPricePer1k, + AudioRealtimePricePerMin: g.AudioRealtimePricePerMin, + AudioTtsPricePerMillionChars: g.AudioTTSPricePerMillionChars, + AudioSttPricePerHour: g.AudioSTTPricePerHour, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest, @@ -702,6 +710,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { return &AdminUsageLog{ UsageLog: usageLog, UpstreamModel: l.UpstreamModel, + UpstreamResponseModel: l.UpstreamResponseModel, + UpstreamModelMismatch: l.UpstreamModelMismatch, ChannelID: l.ChannelID, ModelMappingChain: l.ModelMappingChain, BillingTier: l.BillingTier, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index 6cbcbada56..d396bbfa2f 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test t.Parallel() upstreamModel := "claude-sonnet-4-20250514" + upstreamResponseModel := "claude-sonnet-4-20250513" + upstreamModelMismatch := true log := &service.UsageLog{ - RequestID: "req_4", - Model: upstreamModel, - RequestedModel: "claude-sonnet-4", - UpstreamModel: &upstreamModel, + RequestID: "req_4", + Model: upstreamModel, + RequestedModel: "claude-sonnet-4", + UpstreamModel: &upstreamModel, + UpstreamResponseModel: &upstreamResponseModel, + UpstreamModelMismatch: &upstreamModelMismatch, } userDTO := UsageLogFromService(log) @@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test userJSON, err := json.Marshal(userDTO) require.NoError(t, err) require.NotContains(t, string(userJSON), "upstream_model") + require.NotContains(t, string(userJSON), "upstream_response_model") + require.NotContains(t, string(userJSON), "upstream_model_mismatch") adminJSON, err := json.Marshal(adminDTO) require.NoError(t, err) require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`) + require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`) + require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`) } func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index dc34771cad..5ab5eaa68b 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -27,26 +27,27 @@ type CustomEndpoint struct { // SystemSettings represents the admin settings API response payload. type SystemSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 - PasskeyEnabled bool `json:"passkey_enabled"` - PasskeyConfigured bool `json:"passkey_configured"` - PasskeyRPID string `json:"passkey_rp_id"` - PasskeyRPOrigins []string `json:"passkey_rp_origins"` - SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 - StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA - AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 + PasskeyEnabled bool `json:"passkey_enabled"` + PasskeyConfigured bool `json:"passkey_configured"` + PasskeyRPID string `json:"passkey_rp_id"` + PasskeyRPOrigins []string `json:"passkey_rp_origins"` + SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定 + StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA + AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -56,11 +57,23 @@ type SystemSettings struct { SMTPFromName string `json:"smtp_from_name"` SMTPUseTLS bool `json:"smtp_use_tls"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` - APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` - ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"` + TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"` + TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"` + AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` + ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"` LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` @@ -142,6 +155,7 @@ type SystemSettings struct { ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` HideCcsImportButton bool `json:"hide_ccs_import_button"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` @@ -198,6 +212,9 @@ type SystemSettings struct { EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"` AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent string `json:"openai_codex_user_agent"` + OpenAICodexClientVersion string `json:"openai_codex_client_version"` + OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` + OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` // codex_cli_only 加固 MinCodexVersion string `json:"min_codex_version"` @@ -284,8 +301,15 @@ type SystemSettings struct { AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"` // Channel Monitor feature switch - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + + // Grok model mapping policy (admin settings; empty account mapping falls back to these). + GrokDefaultTextModel string `json:"grok_default_text_model"` + GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -311,6 +335,9 @@ type SystemSettings struct { // 系统全局默认平台配额(key = platform,nil/缺省 = 不限制) DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"` + // 系统全局账号自动停调阈值(key = platform,100 = disabled) + AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds,omitempty"` + // 允许终端用户在用量页查看自己的失败请求 AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } @@ -321,50 +348,59 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - 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"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + 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"` // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 ServerTimezone string `json:"server_timezone"` @@ -374,8 +410,10 @@ type PublicSettings struct { BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index d5ba4e9db6..bc2c502c1f 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -121,8 +121,14 @@ type Group struct { VideoPrice480P *float64 `json:"video_price_480p"` VideoPrice720P *float64 `json:"video_price_720p"` VideoPrice1080P *float64 `json:"video_price_1080p"` + // VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价 (USD/s)。 + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 - WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + SearchPricePer1k *float64 `json:"search_price_per_1k"` + AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` + AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` + AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"` // Claude Code 客户端限制 ClaudeCodeOnly bool `json:"claude_code_only"` @@ -155,6 +161,13 @@ type Group struct { type AdminGroup struct { Group + // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。 + // 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出 + // 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。 + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled bool `json:"model_routing_enabled"` @@ -548,6 +561,10 @@ type AdminUsageLog struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Omitted when no mapping was applied (requested model was used as-is). UpstreamModel *string `json:"upstream_model,omitempty"` + // UpstreamResponseModel is the raw model declared by the upstream response. + UpstreamResponseModel *string `json:"upstream_response_model,omitempty"` + // UpstreamModelMismatch is nil when the upstream did not declare a model. + UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"` // ChannelID 渠道 ID ChannelID *int64 `json:"channel_id,omitempty"` diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index 6b4858c528..edbe4e232a 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -36,12 +36,40 @@ const ( maxSameAccountRetries = 3 // sameAccountRetryDelay 同账号重试间隔 sameAccountRetryDelay = 500 * time.Millisecond + // maxRequestScopedRetryDelay 限制请求级瞬时错误的指数退避上限,避免高重试配置 + // 将单次请求拖入分钟级等待。 + maxRequestScopedRetryDelay = 8 * time.Second // singleAccountBackoffDelay 单账号分组 503 退避重试固定延时。 // Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s), // Handler 层只需短暂间隔后重新进入 Service 层即可。 singleAccountBackoffDelay = 2 * time.Second + // maxProfitVetoAttempts 单次请求内允许的分组利润门终检否决次数上限。 + // 利润否决不产生上游请求,因此不会推进 SwitchCount;没有独立上限的话, + // 「选号 → 终检否决 → 重选」在候选池与账号快照短暂不一致时可以空转很久。 + // 取值与 maxAccountSwitches 默认值一致:混合定价的大分组仍有充分重选机会, + // 同时把整池越线时的无谓选号开销限制在常数级。 + maxProfitVetoAttempts = 10 ) +// profitVetoExhaustedMessage 是利润否决次数耗尽时返回给客户端的文案。 +// 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。 +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 { + return sameAccountRetryDelay + } + + delay := sameAccountRetryDelay + for i := 1; i < retryCount; i++ { + if delay >= maxRequestScopedRetryDelay/2 { + return maxRequestScopedRetryDelay + } + delay *= 2 + } + return delay +} + // FailoverState 跨循环迭代共享的 failover 状态 type FailoverState struct { SwitchCount int @@ -51,18 +79,64 @@ type FailoverState struct { LastFailoverErr *service.UpstreamFailoverError ForceCacheBilling bool hasBoundSession bool + + // profitVetoedAccountIDs 记录被分组利润门终检否决的账号,是 FailedAccountIDs + // 的子集。之所以单独维护:HandleSelectionExhausted 的 503 退避分支会清空 + // FailedAccountIDs,而利润否决在同一请求内的判定不会改变(下游倍率 D 已在 + // 请求开始冻结),被清空的账号会被立即重选并再次否决,形成没有任何上游请求、 + // SwitchCount 也不前进的活锁。清空后必须把它们放回排除集。 + profitVetoedAccountIDs map[int64]struct{} + // profitVetoCount 本次请求累计的利润否决次数,用于 maxProfitVetoAttempts 上限。 + profitVetoCount int } // NewFailoverState 创建 failover 状态 func NewFailoverState(maxSwitches int, hasBoundSession bool) *FailoverState { return &FailoverState{ - MaxSwitches: maxSwitches, - FailedAccountIDs: make(map[int64]struct{}), - SameAccountRetryCount: make(map[int64]int), - hasBoundSession: hasBoundSession, + MaxSwitches: maxSwitches, + FailedAccountIDs: make(map[int64]struct{}), + SameAccountRetryCount: make(map[int64]int), + hasBoundSession: hasBoundSession, + profitVetoedAccountIDs: make(map[int64]struct{}), } } +// RecordProfitVeto 记录一次分组利润门终检否决:把账号加入排除列表(同时登记到 +// 利润否决集,使其不被 503 退避分支清掉)并递增否决计数。 +// +// 返回 FailoverContinue 表示调用方可以继续重选下一个账号;返回 FailoverExhausted +// 表示本次请求的利润否决次数已达上限,调用方应按「无可用账号」终止, +// 不得继续 continue。 +func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction { + s.FailedAccountIDs[accountID] = struct{}{} + if s.profitVetoedAccountIDs == nil { + s.profitVetoedAccountIDs = make(map[int64]struct{}) + } + s.profitVetoedAccountIDs[accountID] = struct{}{} + s.profitVetoCount++ + if s.profitVetoCount >= maxProfitVetoAttempts { + return FailoverExhausted + } + return FailoverContinue +} + +// ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。 +func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount } + +// allExclusionsAreProfitVetoed 判断排除列表是否已全部由利润门否决贡献。 +// 此时清空 FailedAccountIDs 会被原样恢复,退避重试不会带来任何新候选。 +func (s *FailoverState) allExclusionsAreProfitVetoed() bool { + if len(s.profitVetoedAccountIDs) == 0 || len(s.FailedAccountIDs) == 0 { + return false + } + for id := range s.FailedAccountIDs { + if _, ok := s.profitVetoedAccountIDs[id]; !ok { + return false + } + } + return true +} + // HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。 // 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。 func (s *FailoverState) HandleFailoverError( @@ -93,13 +167,15 @@ func (s *FailoverState) HandleFailoverError( // 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。 if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit { s.SameAccountRetryCount[accountID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID]) logger.FromContext(ctx).Warn("gateway.failover_same_account_retry", zap.Int64("account_id", accountID), zap.Int("upstream_status", failoverErr.StatusCode), zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]), zap.Int("same_account_retry_max", retryLimit), + zap.Duration("retry_delay", retryDelay), ) - if !sleepWithContext(ctx, sameAccountRetryDelay) { + if !sleepWithContext(ctx, retryDelay) { return FailoverCanceled } return FailoverContinue @@ -156,6 +232,17 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable && s.SwitchCount <= s.MaxSwitches { + // 排除列表全由利润门否决贡献时,清空后会被原样恢复:退避重试拿不到 + // 任何新候选,而利润否决不推进 SwitchCount,退避条件将永远成立。 + // 这里直接判定耗尽,避免每 2s 空转一轮的活锁。 + if s.allExclusionsAreProfitVetoed() { + logger.FromContext(ctx).Warn("gateway.failover_selection_exhausted_by_profit_veto", + zap.Int("profit_veto_count", s.profitVetoCount), + zap.Int("excluded_accounts", len(s.FailedAccountIDs)), + ) + return FailoverExhausted + } + logger.FromContext(ctx).Warn("gateway.failover_single_account_backoff", zap.Duration("backoff_delay", singleAccountBackoffDelay), zap.Int("switch_count", s.SwitchCount), @@ -169,6 +256,11 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc zap.Int("max_switches", s.MaxSwitches), ) s.FailedAccountIDs = make(map[int64]struct{}) + // 利润门否决的账号不参与退避重试的解除:判定依据(冻结的下游倍率)在 + // 同一请求内不变,放它们回池只会被再次否决。 + for id := range s.profitVetoedAccountIDs { + s.FailedAccountIDs[id] = struct{}{} + } return FailoverContinue } return FailoverExhausted diff --git a/backend/internal/handler/failover_loop_profit_veto_test.go b/backend/internal/handler/failover_loop_profit_veto_test.go new file mode 100644 index 0000000000..c417c27e10 --- /dev/null +++ b/backend/internal/handler/failover_loop_profit_veto_test.go @@ -0,0 +1,139 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// profitVetoLoopResult 记录一次模拟选号循环的终止方式与步数。 +type profitVetoLoopResult struct { + outcome string // "forwarded" | "exhausted" | "budget_exceeded" + forwardedID int64 + iterations int + backoffRetries int +} + +// runProfitVetoLoop 模拟 handler 的「选号 → 利润终检 → 重选」循环,只保留与 +// 活锁相关的状态机(FailoverState + 排除列表 + HandleSelectionExhausted), +// 不涉及真实调度器与上游转发。 +// +// pool 按顺序给出候选账号;vetoed 中的账号每次终检都被利润门否决——这对应 +// 「候选池快照与 per-account 快照短暂不一致」时门的确定性判定。 +// maxIterations 是测试自身的预算:循环超过它即视为活锁。 +func runProfitVetoLoop(t *testing.T, fs *FailoverState, pool []int64, vetoed map[int64]bool, maxIterations int) profitVetoLoopResult { + t.Helper() + res := profitVetoLoopResult{} + for res.iterations = 1; res.iterations <= maxIterations; res.iterations++ { + // 选号:返回第一个不在排除列表中的账号。 + var picked int64 + for _, id := range pool { + if _, excluded := fs.FailedAccountIDs[id]; !excluded { + picked = id + break + } + } + if picked == 0 { + // 选号耗尽,交给退避决策。 + switch fs.HandleSelectionExhausted(context.Background()) { + case FailoverContinue: + res.backoffRetries++ + continue + default: + res.outcome = "exhausted" + return res + } + } + if vetoed[picked] { + if fs.RecordProfitVeto(picked) == FailoverExhausted { + res.outcome = "exhausted" + return res + } + continue + } + res.outcome = "forwarded" + res.forwardedID = picked + return res + } + res.outcome = "budget_exceeded" + return res +} + +// TestProfitVetoAfter503DoesNotLivelock 钉死 #4925 引入的活锁回归: +// 一次真实 503 之后,若调度器持续返回会被利润门否决的账号, +// 「选号 → 否决 → 排除 → 选号耗尽 → 清空排除 → 睡 2s → 选号」会无限循环, +// 因为利润否决不推进 SwitchCount,退避条件永远成立。 +func TestProfitVetoAfter503DoesNotLivelock(t *testing.T) { + fs := NewFailoverState(10, false) + // 已经历一次真实 503(Antigravity 单账号分组 MODEL_CAPACITY_EXHAUSTED 是设计内路径)。 + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[1] = struct{}{} + + start := time.Now() + res := runProfitVetoLoop(t, fs, []int64{1}, map[int64]bool{1: true}, 50) + elapsed := time.Since(start) + + require.Equal(t, "exhausted", res.outcome, "整池被利润门否决时必须有限步终止") + require.LessOrEqual(t, res.backoffRetries, 1, "被否决的账号不得被退避分支复活并反复重选") + require.Less(t, elapsed, 10*time.Second, "不得每 2s 空转一轮") +} + +// TestProfitVetoKeepsBackoffUsefulForHealthyAccount 钉死修复没有削弱既有的 +// 503 退避语义:排除列表里仍有非利润否决的账号时,退避照常清空并重试, +// 只是被利润门否决的账号不再复活。 +func TestProfitVetoKeepsBackoffUsefulForHealthyAccount(t *testing.T) { + fs := NewFailoverState(10, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + // 账号 1 因真实 503 被排除;账号 2 会被利润门否决。 + fs.FailedAccountIDs[1] = struct{}{} + + res := runProfitVetoLoop(t, fs, []int64{2, 1}, map[int64]bool{2: true}, 50) + + require.Equal(t, "forwarded", res.outcome) + require.Equal(t, int64(1), res.forwardedID, "退避清空后应重新可选账号 1") + require.Equal(t, 1, res.backoffRetries, "应发生且只发生一次退避重试") + require.Contains(t, fs.FailedAccountIDs, int64(2), "利润否决的账号在退避清空后必须被放回排除集") +} + +// TestProfitVetoAttemptsCapped 钉死没有 503 参与时,大分组整池越线也会在 +// 常数步内终止,而不是把整池逐个选一遍。 +func TestProfitVetoAttemptsCapped(t *testing.T) { + fs := NewFailoverState(10, false) + pool := make([]int64, 0, 64) + vetoed := make(map[int64]bool, 64) + for id := int64(1); id <= 64; id++ { + pool = append(pool, id) + vetoed[id] = true + } + + res := runProfitVetoLoop(t, fs, pool, vetoed, 200) + + require.Equal(t, "exhausted", res.outcome) + require.Equal(t, maxProfitVetoAttempts, fs.ProfitVetoCount()) + require.Equal(t, maxProfitVetoAttempts, res.iterations, "达到上限即终止,不应继续遍历候选池") +} + +// TestRecordProfitVetoExcludesAccount 钉死 RecordProfitVeto 仍然把账号加入 +// 调度排除列表(选号入参用的就是 FailedAccountIDs)。 +func TestRecordProfitVetoExcludesAccount(t *testing.T) { + fs := NewFailoverState(10, false) + require.Equal(t, FailoverContinue, fs.RecordProfitVeto(42)) + require.Contains(t, fs.FailedAccountIDs, int64(42)) + require.Equal(t, 1, fs.ProfitVetoCount()) +} + +// TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto 钉死未启用利润控制的 +// 请求(无任何利润否决)走的仍是原有退避语义:清空排除列表并重试。 +func TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto(t *testing.T) { + fs := NewFailoverState(3, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[100] = struct{}{} + + require.Equal(t, FailoverContinue, fs.HandleSelectionExhausted(context.Background())) + require.Empty(t, fs.FailedAccountIDs, "无利润否决时排除列表应被完全清空") +} diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index 34644711eb..e65b43c172 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -31,6 +31,35 @@ func (m *mockTempUnscheduler) TempUnscheduleRetryableError(_ context.Context, ac m.calls = append(m.calls, tempUnscheduleCall{accountID: accountID, failoverErr: failoverErr}) } +func TestSameAccountRetryDelayFor(t *testing.T) { + capacityErr := &service.UpstreamFailoverError{RequestScopedTransient: true} + + for _, tc := range []struct { + name string + retryCount int + want time.Duration + }{ + {name: "first retry", retryCount: 1, want: 500 * time.Millisecond}, + {name: "second retry", retryCount: 2, want: time.Second}, + {name: "third retry", retryCount: 3, want: 2 * time.Second}, + {name: "fourth retry", retryCount: 4, want: 4 * time.Second}, + {name: "fifth retry", retryCount: 5, want: 8 * time.Second}, + {name: "capped retry", retryCount: 10, want: 8 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, sameAccountRetryDelayFor(capacityErr, tc.retryCount)) + }) + } + + t.Run("non request scoped errors keep fixed delay", func(t *testing.T) { + require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(&service.UpstreamFailoverError{}, 10)) + }) + + t.Run("nil error keeps fixed delay", func(t *testing.T) { + require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(nil, 10)) + }) +} + // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index d09b9e5b73..1e2d0fbe3d 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -196,6 +196,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) // 验证 model 必填 if reqModel == "" { @@ -421,8 +423,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // Slot acquired: no longer waiting in queue. releaseWait() - if err := h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, account.ID); err != nil { - reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + // 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。 + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + markOpsRoutingCapacityLimited(c) + h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) + return + } + continue + } + account = latest + selection.Account = latest + // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已 + // 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 + if selection.ProfitGateActive() || !selection.Acquired { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil { + reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } } // 账号槽位/等待计数需要在超时或断开时安全回收 @@ -544,6 +568,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + PricingAt: pricingAt, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, @@ -721,12 +746,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // Slot acquired: no longer waiting in queue. releaseWait() - reqLog.Info("sticky.bind_after_wait", - zap.String("session_key", sessionKey), - zap.Int64("account_id", account.ID), - ) - if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil { - reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + // 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。 + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + markOpsRoutingCapacityLimited(c) + h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) + return + } + continue + } + account = latest + selection.Account = latest + // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已 + // 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 + if selection.ProfitGateActive() || !selection.Acquired { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, currentAPIKey.GroupID, sessionKey, account.ID); err != nil { + reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } } // 账号槽位/等待计数需要在超时或断开时安全回收 @@ -827,6 +870,66 @@ func (h *GatewayHandler) Messages(c *gin.Context) { if accountReleaseFunc != nil { accountReleaseFunc() } + + // 提交 usage 记录。成功路径与"流中断但 Forward 已观测到 usage 的部分结果" + // 错误路径共用:后者若不入账,上游已计量的请求会完全漏记漏计费(#5148)。 + submitForwardUsage := func(result *service.ForwardResult) { + // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + // Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。 + requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes()) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + + if result.ReasoningEffort == nil { + result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort) + } + // 同上(重试路径中的对称填充)。详见非重试路径同名注释。 + if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled { + protocolModel := result.UpstreamModel + if protocolModel == "" { + protocolModel = result.Model + } + result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel) + } + + // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 + // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 + forceCacheBilling := fs.ForceCacheBilling + quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey) + sessionID := service.ExtractClientSessionID(c) + h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: result, + QuotaPlatform: quotaPlatform, + APIKey: currentAPIKey, + User: currentAPIKey.User, + Account: account, + Subscription: currentSubscription, + PricingAt: pricingAt, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + SessionID: sessionID, + RequestPayloadHash: requestPayloadHash, + ForceCacheBilling: forceCacheBilling, + APIKeyService: h.apiKeyService, + ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + }); err != nil { + logger.L().With( + zap.String("component", "handler.gateway.messages"), + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", currentAPIKey.ID), + zap.Any("group_id", currentAPIKey.GroupID), + zap.String("model", reqModel), + zap.Int64("account_id", account.ID), + ).Error("gateway.record_usage_failed", zap.Error(err)) + } + }) + } + if err != nil { // Beta policy block: return 400 immediately, no failover var betaBlockedErr *service.BetaBlockedError @@ -925,6 +1028,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID)) } reqLog.Error("gateway.forward_failed", forwardFailedFields...) + // Forward 与错误一起返回的部分结果:流中断前上游已计量的 usage 照常入账, + // 避免上游已产生消耗的请求完全漏记(#5148)。failover 错误恒定 result=nil, + // 不会走到这里重复计费。 + if result != nil { + submitForwardUsage(result) + } return } @@ -948,59 +1057,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } } - // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) - userAgent := c.GetHeader("User-Agent") - clientIP := ip.GetClientIP(c) - // Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。 - requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes()) - inboundEndpoint := GetInboundEndpoint(c) - upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) - - if result.ReasoningEffort == nil { - result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort) - } - // 同上(重试路径中的对称填充)。详见非重试路径同名注释。 - if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled { - protocolModel := result.UpstreamModel - if protocolModel == "" { - protocolModel = result.Model - } - result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel) - } - - // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 - // ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。 - forceCacheBilling := fs.ForceCacheBilling - quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey) - sessionID := service.ExtractClientSessionID(c) - h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { - if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ - Result: result, - QuotaPlatform: quotaPlatform, - APIKey: currentAPIKey, - User: currentAPIKey.User, - Account: account, - Subscription: currentSubscription, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - SessionID: sessionID, - RequestPayloadHash: requestPayloadHash, - ForceCacheBilling: forceCacheBilling, - APIKeyService: h.apiKeyService, - ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), - }); err != nil { - logger.L().With( - zap.String("component", "handler.gateway.messages"), - zap.Int64("user_id", subject.UserID), - zap.Int64("api_key_id", currentAPIKey.ID), - zap.Any("group_id", currentAPIKey.GroupID), - zap.String("model", reqModel), - zap.Int64("account_id", account.ID), - ).Error("gateway.record_usage_failed", zap.Error(err)) - } - }) + submitForwardUsage(result) return } if !retryWithFallback { @@ -2336,10 +2393,16 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv } task = wrapUsageRecordTaskContext(parent, task) if h.usageRecordWorkerPool != nil { - h.usageRecordWorkerPool.Submit(task) - return + if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped { + return + } + // 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。 + // 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。 + logger.L().With( + zap.String("component", "handler.gateway.messages"), + ).Warn("gateway.usage_record_task_stopped_sync_fallback") } - // 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。 + // 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() defer func() { @@ -2353,6 +2416,33 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv task(ctx) } +// submitMandatoryUsageRecordTask never silently drops billing work on pool overflow. +func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context, task service.UsageRecordTask) { + if task == nil { + return + } + task = wrapUsageRecordTaskContext(parent, task) + if h.usageRecordWorkerPool != nil { + if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() { + return + } + logger.L().With( + zap.String("component", "handler.gateway.usage"), + ).Warn("gateway.usage_record_task_mandatory_sync_fallback") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + defer func() { + if recovered := recover(); recovered != nil { + logger.L().With( + zap.String("component", "handler.gateway.usage"), + zap.Any("panic", recovered), + ).Error("gateway.usage_record_task_panic_recovered") + } + }() + task(ctx) +} + // getUserMsgQueueMode 获取当前请求的 UMQ 模式 // 返回 "serialize" | "throttle" | "" func (h *GatewayHandler) getUserMsgQueueMode(account *service.Account, parsed *service.ParsedRequest) string { diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 3bbac76adb..ea4e839636 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -93,6 +93,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) @@ -157,7 +159,6 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { if groupPlatform == service.PlatformGemini && selectionSessionHash != "" { selectionSessionHash = "gemini:" + selectionSessionHash } - // 3. Account selection + failover loop fs := NewFailoverState(h.maxAccountSwitches, false) if groupPlatform == service.PlatformGemini { @@ -223,6 +224,28 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { return } } + // 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。 + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("gateway.cc.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("gateway.cc.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage) + return + } + continue + } + account = latest + selection.Account = latest + if selection.ProfitGateActive() { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, selectionSessionHash, account.ID); err != nil { + reqLog.Warn("gateway.cc.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + } accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc) if groupPlatform == service.PlatformGemini && account.Platform != service.PlatformGemini { @@ -318,6 +341,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + PricingAt: pricingAt, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 5653438613..7f49f39a3e 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -90,9 +90,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) requestCtx := c.Request.Context() + // 定价上下文无条件装配:/v1/responses 是 token 计费端点,声明生图工具的 + // 混合请求同样按 token 计费(外加图片部分),其 token 利润保护不因请求体 + // 里的任何工具声明(含 Codex 被动 image_gen namespace)而关闭。生图意图 + // 仅用于能力路由与图片计费;独立图片/视频端点才在利润门范围之外。 + requestCtx, pricingAt := service.WithGatewayTokenRequestPricing(requestCtx) if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(c.Request.Context(), apiKey)) { requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx) } + c.Request = c.Request.WithContext(requestCtx) // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel) @@ -220,6 +226,30 @@ func (h *GatewayHandler) Responses(c *gin.Context) { return } } + // 终检与准入后绑定必须使用选号结果携带的门:门安装在调度栈的局部 + // ctx 上(composite/fallback 还可能解析出与入口分组不同的门),直接用 + // requestCtx 会退化为空操作。 + admissionCtx := service.ContextWithSelectionProfitGate(requestCtx, selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("gateway.responses.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("gateway.responses.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage) + return + } + continue + } + account = latest + selection.Account = latest + if selection.ProfitGateActive() { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionHash, account.ID); err != nil { + reqLog.Warn("gateway.responses.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + } accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc) // 5. Forward request @@ -299,6 +329,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + PricingAt: pricingAt, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, diff --git a/backend/internal/handler/gateway_web_search.go b/backend/internal/handler/gateway_web_search.go new file mode 100644 index 0000000000..a0a249f252 --- /dev/null +++ b/backend/internal/handler/gateway_web_search.go @@ -0,0 +1,509 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/websearch" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +const ( + defaultGrokWebSearchResults = 5 + maxGrokWebSearchResults = 20 +) + +func (h *GatewayHandler) WebSearch(c *gin.Context) { + type webSearchReq struct { + Query string `json:"query" binding:"required"` + MaxResults int `json:"max_results"` + } + + var req webSearchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": err.Error(), + }}) + return + } + req.MaxResults = normalizeGrokWebSearchMaxResults(req.MaxResults) + + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{ + "type": "authentication_error", + "message": "API key required", + }}) + return + } + + if apiKey.Group == nil || apiKey.Group.Platform != "grok" { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": "web search is only supported for grok groups", + }}) + return + } + + // Billing eligibility (same as other requests) + 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 { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}}) + return + } + + subject, _ := middleware2.GetAuthSubjectFromContext(c) + reqLog := requestLogger(c, "handler.gateway.web_search") + // Audit user search query before upstream Grok web_search traffic. + auditBody, _ := json.Marshal(map[string]any{ + "messages": []map[string]any{{ + "role": "user", "content": req.Query, + }}, + }) + if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, xai.DefaultTextModel, auditBody); decision != nil && !decision.AllowNextStage { + status := decision.HTTPStatus + if status == 0 { + status = http.StatusForbidden + } + code := decision.ErrorCode + if code == "" { + code = "content_policy_violation" + } + msg := decision.ClientMessage + if msg == "" { + msg = "Request blocked by content policy" + } + c.JSON(status, gin.H{"error": gin.H{"type": code, "message": msg}}) + return + } + + // Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.) + groupID := apiKey.GroupID + if groupID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ + "type": "invalid_request_error", + "message": "group required", + }}) + return + } + + failedAccounts := make(map[int64]struct{}) + var account *service.Account + var accountReleaseFunc func() + var nativeResp *websearch.SearchResponse + var providerName string + var err error + + // Acquire + release holder for the whole handler (including failover retries). + defer func() { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + }() + + // First attempt + up to 3 failover accounts (max 4 total). + for attempt := 0; attempt < 4; attempt++ { + selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness( + c.Request.Context(), groupID, "", xai.DefaultTextModel, failedAccounts, "", 0, + ) + if selectErr != nil { + if attempt == 0 { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": selectErr.Error(), + }}) + return + } + break + } + if selected == nil || selected.Account == nil { + if attempt == 0 { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": "No available accounts", + }}) + return + } + break + } + + release, acquireOK, acquireErr := h.acquireWebSearchAccountSlot(c, selected) + if !acquireOK { + // First hop: surface concurrency errors; later hops try another account. + if attempt == 0 && acquireErr != nil { + h.handleConcurrencyError(c, acquireErr, "account", false) + return + } + failedAccounts[selected.Account.ID] = struct{}{} + continue + } + account = selected.Account + accountReleaseFunc = release + + nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, req.MaxResults) + if err == nil { + break + } + var failoverErr *service.UpstreamFailoverError + if !errors.As(err, &failoverErr) || !failoverErr.ShouldRetryNextAccount() { + break + } + failedAccounts[account.ID] = struct{}{} + if accountReleaseFunc != nil { + accountReleaseFunc() + accountReleaseFunc = nil + } + account = nil + } + if err != nil || nativeResp == nil { + msg := "web search failed" + if err != nil { + msg = err.Error() + } + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "web_search_error", "message": msg}}) + return + } + if account == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ + "type": "scheduling_error", + "message": "No available accounts", + }}) + return + } + + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query)) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + // Request IDs are billing idempotency keys, so they must be unique per invocation. + // Query/IP/UA hashes would collapse repeated identical searches into one charge. + searchRequestID := "web_search:" + uuid.NewString() + if apiKey.Group != nil { + if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 { + logger.L().With( + zap.String("component", "handler.gateway.web_search"), + zap.Int64("group_id", apiKey.Group.ID), + ).Info("gateway.web_search.search_price_per_1k_explicit_free") + } + } + h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: &service.ForwardResult{ + RequestID: searchRequestID, + Model: "grok-web-search", + SearchCount: 1, + Duration: 0, + }, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + }); err != nil { + logger.L().With( + zap.String("component", "handler.gateway.web_search"), + zap.Int64("user_id", apiKey.User.ID), + zap.Int64("api_key_id", apiKey.ID), + zap.Int64("account_id", account.ID), + ).Error("gateway.web_search.record_usage_failed", zap.Error(err)) + } + }) + + c.JSON(http.StatusOK, gin.H{ + "query": req.Query, + "results": nativeResp.Results, + "provider": providerName, + "max_results": req.MaxResults, + }) +} + +// acquireWebSearchAccountSlot resolves an immediate slot or WaitPlan wait. +// On failure returns (nil, false, err); err is non-nil for concurrency acquire +// failures so the first hop can map them to HTTP. Wait-queue full returns +// (nil, false, nil) so failover can try another account. +func (h *GatewayHandler) acquireWebSearchAccountSlot( + c *gin.Context, + selected *service.AccountSelectionResult, +) (release func(), ok bool, acquireErr error) { + if selected == nil || selected.Account == nil { + return nil, false, nil + } + if selected.Acquired { + return selected.ReleaseFunc, true, nil + } + if selected.WaitPlan == nil || h.concurrencyHelper == nil { + return nil, false, nil + } + account := selected.Account + accountWaitCounted := false + canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting) + if waitErr != nil { + logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed", + zap.Int64("account_id", account.ID), + zap.Error(waitErr), + ) + // Best-effort wait without counter (same as first-hop legacy path). + } else if !canWait { + return nil, false, nil + } else { + accountWaitCounted = true + } + releaseWait := func() { + if accountWaitCounted { + h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID) + accountWaitCounted = false + } + } + streamStarted := false + slotRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout( + c, + account.ID, + selected.WaitPlan.MaxConcurrency, + selected.WaitPlan.Timeout, + false, + &streamStarted, + ) + releaseWait() + if err != nil { + return nil, false, err + } + return slotRelease, true, nil +} + +// doGrokNativeWebSearch executes web search using the Grok account's native capability +// by calling the responses endpoint with web_search tool, then normalizes sources to unified format. +func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int) (*websearch.SearchResponse, string, error) { + maxResults = normalizeGrokWebSearchMaxResults(maxResults) + + // Build a minimal responses request that triggers Grok web search tool. + // Ask for structured metadata because xAI action.sources commonly contains URLs only. + searchBody := map[string]any{ + "model": xai.DefaultTextModel, + "input": buildGrokWebSearchPrompt(query, maxResults), + "tools": []map[string]any{{"type": "web_search"}}, + "include": []string{"web_search_call.action.sources"}, + "store": false, + "stream": false, + } + bodyBytes, _ := json.Marshal(searchBody) + + respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes) + if err != nil { + return nil, "", err + } + + // Extract sources from Grok responses output. + // Prefer web_search_call.action.sources (standardized), fallback to annotations or text links. + results := extractGrokWebSearchSources(respBytes, maxResults) + + return &websearch.SearchResponse{ + Results: results, + Query: query, + }, "grok-native", nil +} + +func normalizeGrokWebSearchMaxResults(maxResults int) int { + if maxResults <= 0 { + return defaultGrokWebSearchResults + } + if maxResults > maxGrokWebSearchResults { + return maxGrokWebSearchResults + } + return maxResults +} + +func buildGrokWebSearchPrompt(query string, maxResults int) string { + return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown. + +User query: +%s`, normalizeGrokWebSearchMaxResults(maxResults), query) +} + +// extractGrokWebSearchSources returns model-enriched results only when their URLs +// are present in the actual web_search sources, then falls back to raw sources. +func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult { + if len(body) == 0 || !gjson.ValidBytes(body) { + return nil + } + maxResults = normalizeGrokWebSearchMaxResults(maxResults) + + sources := make(map[string]websearch.SearchResult) + var sourceOrder []string + addSource := func(rawURL, title, snippet string) { + key, ok := normalizeGrokWebSearchURL(rawURL) + if !ok { + return + } + result, exists := sources[key] + if !exists { + result.URL = strings.TrimSpace(rawURL) + sourceOrder = append(sourceOrder, key) + } + if result.Title == "" { + result.Title = usableGrokWebSearchTitle(title, result.URL) + } + if result.Snippet == "" { + result.Snippet = strings.TrimSpace(snippet) + } + sources[key] = result + } + + output := gjson.GetBytes(body, "output") + output.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "web_search_call" { + sources := item.Get("action.sources") + if sources.IsArray() { + sources.ForEach(func(_, src gjson.Result) bool { + addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String()) + return true + }) + } + } + if item.Get("type").String() == "message" { + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "output_text" { + return true + } + part.Get("annotations").ForEach(func(_, ann gjson.Result) bool { + if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" { + addSource(ann.Get("url").String(), ann.Get("title").String(), "") + } + return true + }) + return true + }) + } + return true + }) + + var out []websearch.SearchResult + seen := make(map[string]bool) + output.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() != "message" { + return true + } + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "output_text" || len(out) >= maxResults { + return true + } + for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) { + key, ok := normalizeGrokWebSearchURL(result.URL) + if !ok || seen[key] { + continue + } + source, allowed := sources[key] + if !allowed { + continue + } + seen[key] = true + result.URL = source.URL + result.Title = usableGrokWebSearchTitle(result.Title, result.URL) + if result.Title == "" { + result.Title = source.Title + } + result.Snippet = strings.TrimSpace(result.Snippet) + if result.Snippet == "" { + result.Snippet = source.Snippet + } + out = append(out, result) + if len(out) >= maxResults { + break + } + } + return true + }) + return len(out) < maxResults + }) + + for _, key := range sourceOrder { + if len(out) >= maxResults { + break + } + if seen[key] { + continue + } + result := sources[key] + if result.Title == "" { + result.Title = grokWebSearchTitleFromURL(result.URL) + } + seen[key] = true + out = append(out, result) + } + return out +} + +func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult { + text = strings.TrimSpace(text) + start := strings.IndexByte(text, '{') + end := strings.LastIndexByte(text, '}') + if start < 0 || end < start { + return nil + } + var payload struct { + Results []websearch.SearchResult `json:"results"` + } + if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil { + return nil + } + return payload.Results +} + +func normalizeGrokWebSearchURL(rawURL string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "", false + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + u.Fragment = "" + if u.Path == "" { + u.Path = "/" + } + return u.String(), true +} + +func usableGrokWebSearchTitle(title, rawURL string) string { + title = strings.TrimSpace(title) + if title == "" || title == rawURL { + return "" + } + if _, err := strconv.Atoi(title); err == nil { + return "" + } + return title +} + +func grokWebSearchTitleFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return rawURL + } + return strings.TrimPrefix(strings.ToLower(u.Host), "www.") +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 40470ac418..2a270d3a0a 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -98,6 +98,12 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { googleError(c, http.StatusBadRequest, "Missing model in URL") return } + // 模型名会被拼进上游 URL 的 path,先在入口校验片段合规性, + // 见 service/upstream_path_guard.go。 + if !service.IsSafeGeminiModelPathSegment(modelName) { + googleError(c, http.StatusBadRequest, "Invalid model in URL") + return + } if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } @@ -169,6 +175,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { googleError(c, http.StatusNotFound, err.Error()) return } + // URL 里的模型名最终会被拼进上游 /v1beta/models/{model}:{action}, + // 先在入口校验片段合规性,见 service/upstream_path_guard.go。 + if !service.IsSafeGeminiModelPathSegment(modelName) { + googleError(c, http.StatusBadRequest, "Invalid model in URL") + return + } if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" { modelName = strings.TrimSpace(resolvedModel) } @@ -192,6 +204,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { setOpsRequestContext(c, modelName, stream) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false))) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage { googleSecurityAuditError(c, decision) @@ -454,8 +468,30 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { geminiConcurrency.DecrementAccountWaitCount(c.Request.Context(), account.ID) accountWaitCounted = false } - if err := h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, account.ID); err != nil { - reqLog.Warn("gemini.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + // 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。 + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if fs.RecordProfitVeto(account.ID) == FailoverExhausted { + reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) + markOpsRoutingCapacityLimited(c) + googleError(c, http.StatusServiceUnavailable, profitVetoExhaustedMessage) + return + } + continue + } + account = latest + selection.Account = latest + // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已抢槽 + // 的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 + if selection.ProfitGateActive() || !selection.Acquired { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil { + reqLog.Warn("gemini.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) } } // 账号槽位/等待计数需要在超时或断开时安全回收 @@ -541,6 +577,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { User: apiKey.User, Account: account, Subscription: subscription, + PricingAt: pricingAt, InboundEndpoint: inboundEndpoint, UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go new file mode 100644 index 0000000000..542d70d423 --- /dev/null +++ b/backend/internal/handler/grok_audio.go @@ -0,0 +1,338 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// GrokRealtime exposes xAI's native Voice Realtime WebSocket. +// Only Grok-platform API keys may use this endpoint. +func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { + if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) { + h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)") + return + } + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform") + return + } + if !h.ensureResponsesDependencies(c, nil) { + 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 { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + 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 { + 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 + } + + conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + + model := c.Query("model") + if strings.TrimSpace(model) == "" { + model = "grok-voice-latest" + } + started := time.Now() + proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model) + elapsed := time.Since(started) + if proxyErr != nil { + reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr)) + if !isExpectedGrokRealtimeClose(proxyErr) { + _ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed") + return + } + } + // A relay normally returns a close error when either side closes normally. + // Those sessions still consumed upstream audio time and must be billed. + if elapsed > 0 { + result := &service.OpenAIForwardResult{ + // One durable id per WS session so retries cannot collapse or double under client ids. + RequestID: service.StableGrokRealtimeBillingRequestID(""), + Model: model, + Duration: elapsed, + AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()}, + } + h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result) + } +} + +func isExpectedGrokRealtimeClose(err error) bool { + if err == nil { + return true + } + switch coderws.CloseStatus(err) { + case coderws.StatusNormalClosure, coderws.StatusGoingAway, + coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure: + return true + default: + return false + } +} + +// GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices". +func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform") + return + } + if !h.ensureResponsesDependencies(c, nil) { + 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 { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + body, err := readGrokVoiceGatewayBody(c) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + if endpoint == "tts" { + subject, _ := middleware2.GetAuthSubjectFromContext(c) + reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint)) + // TTS bodies use {"input":"..."} (and variants). Normalize to chat messages so + // content moderation extractors see the spoken text. + auditBody := body + if input := extractGrokTTSInputText(body); input != "" { + if b, err := json.Marshal(map[string]any{ + "messages": []map[string]any{{"role": "user", "content": input}}, + }); err == nil { + auditBody = b + } + } + if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "grok-4.5", auditBody); decision != nil && !decision.AllowNextStage { + h.openAISecurityAuditError(c, decision) + return + } + } + contentType := c.GetHeader("Content-Type") + if strings.TrimSpace(contentType) == "" { + contentType = "application/json" + } + + failed := map[int64]struct{}{} + var last *service.UpstreamFailoverError + reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint)) + selectionModel := "grok-4.5" + + for attempts := 0; attempts < 4; attempts++ { + selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability( + c.Request.Context(), + apiKey.GroupID, + "", + "", + selectionModel, + failed, + service.OpenAIUpstreamTransportHTTPSSE, + service.OpenAIEndpointCapabilityChatCompletions, + false, + false, + false, + service.PlatformGrok, + ) + if selectErr != nil || selection == nil || selection.Account == nil { + if last != nil { + h.handleFailoverExhausted(c, last, false) + } else { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts") + } + return + } + account := selection.Account + var started bool + release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog) + if status == openAISlotAcquireProfitVetoed { + failed[account.ID] = struct{}{} + continue + } + if status != openAISlotAcquireOK { + // Failed already wrote error response (or transient reject). + if status == openAISlotAcquireFailed && len(failed) == 0 { + // Slot path wrote the response; stop. + return + } + failed[account.ID] = struct{}{} + continue + } + result, forwardErr := func() (*service.OpenAIForwardResult, error) { + defer release() + return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType) + }() + if forwardErr == nil { + h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result) + return + } + var failoverErr *service.UpstreamFailoverError + if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() { + failed[account.ID] = struct{}{} + last = failoverErr + continue + } + // Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response. + return + } + if last != nil { + h.handleFailoverExhausted(c, last, false) + } +} + +// recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set. +func (h *OpenAIGatewayHandler) recordGrokVoiceUsage( + c *gin.Context, + apiKey *service.APIKey, + account *service.Account, + subscription *service.UserSubscription, + endpoint string, + body []byte, + result *service.OpenAIForwardResult, +) { + if h == nil || c == nil || apiKey == nil || account == nil || result == nil { + return + } + if result.AudioUsage == nil { + return + } + // Ensure forced durable request ids even if callers forget (realtime/tts/stt money path). + if mode := strings.TrimSpace(result.AudioUsage.Mode); mode == "realtime" { + result.RequestID = service.StableGrokRealtimeBillingRequestID(result.RequestID) + } else { + result.RequestID = service.StableGrokAudioBillingRequestID(result.RequestID) + } + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + sessionID := service.ExtractClientSessionID(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + if requestPayloadHash == "" { + requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint)) + } + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + model := strings.TrimSpace(result.Model) + if model == "" { + model = endpoint + } + + h.submitMandatoryUsageRecordTask(c.Request.Context(), 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, service.ChannelMappingResult{}, model, result.UpstreamModel), + }); err != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.grok_voice"), + zap.Int64("user_id", apiKey.User.ID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + zap.String("endpoint", endpoint), + zap.Int64("account_id", account.ID), + ).Error("grok_voice.record_usage_failed", zap.Error(err)) + } + }) +} + +func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) { + if c == nil || c.Request == nil { + return nil, errors.New("request body is required") + } + if c.Request.Body == nil { + if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodDelete { + return nil, nil + } + return nil, errors.New("request body is required") + } + return io.ReadAll(c.Request.Body) +} + +// extractGrokTTSInputText pulls the primary spoken text from a TTS JSON body. +func extractGrokTTSInputText(body []byte) string { + if len(body) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + for _, key := range []string{"input", "text", "prompt"} { + if v, ok := payload[key]; ok { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + } + } + return "" +} diff --git a/backend/internal/handler/grok_audio_billing_test.go b/backend/internal/handler/grok_audio_billing_test.go new file mode 100644 index 0000000000..6b8a6c81be --- /dev/null +++ b/backend/internal/handler/grok_audio_billing_test.go @@ -0,0 +1,25 @@ +//go:build unit + +package handler + +import ( + "testing" + + coderws "github.com/coder/websocket" +) + +func TestIsExpectedGrokRealtimeClose(t *testing.T) { + for _, status := range []coderws.StatusCode{ + coderws.StatusNormalClosure, + coderws.StatusGoingAway, + coderws.StatusNoStatusRcvd, + coderws.StatusAbnormalClosure, + } { + if !isExpectedGrokRealtimeClose(coderws.CloseError{Code: status}) { + t.Fatalf("status %v should be treated as an expected session close", status) + } + } + if isExpectedGrokRealtimeClose(coderws.CloseError{Code: coderws.StatusPolicyViolation}) { + t.Fatal("policy violations must not be treated as billable normal closes") + } +} diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index fd0697884e..3cef97ed3f 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -177,13 +177,21 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. return } } - requestCtx := c.Request.Context() + // Grok 媒体(图片/视频生成与视频查询)按媒体倍率计费,不在 token 利润门 + // 范围内:显式豁免,防止 service 层防御性装门按文本 D 误过滤媒体请求, + // 也防止已计费的在途视频任务因绑定账号被门排除而查询返回伪 404。 + requestCtx := service.WithOpenAIProfitControlSuppressed(c.Request.Context()) + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState mediaEligibilityRejected := false switchCount := 0 + videoCreateStartedAt := "" + if isGrokVideoCreateEndpoint(endpoint) { + videoCreateStartedAt = service.GrokVideoPendingCreatedAtNow() + } maxAccountSwitches := h.maxAccountSwitches if maxAccountSwitches <= 0 { maxAccountSwitches = 3 @@ -293,8 +301,17 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account) setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog) - if !accountAcquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 媒体路径已显式豁免利润门(suppress 标记),此分支仅防御性兜底, + // 同样受否决上限约束。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } @@ -393,7 +410,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil) - if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" { + if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" { if err := h.gatewayService.BindGrokMediaVideoRequestAccount( requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID, ); err != nil { @@ -403,8 +420,44 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. zap.Error(err), ) } + // Defer billing until status polling observes video.url. Persist create-time + // model/duration/resolution so status can still price if upstream omits them. + // Retry once: missing pending causes silent underpricing (status omits resolution). + pending := service.GrokVideoPendingBilling{ + Model: requestModel, + BillingModel: firstNonEmptyString(result.BillingModel, requestModel), + UpstreamModel: result.UpstreamModel, + VideoResolution: result.VideoResolution, + VideoDurationSeconds: result.VideoDurationSeconds, + OriginalModel: clientRequestedModel(c, requestModel), + // Wall-clock start for usage duration_ms: create accepted → first done discovery. + CreatedAt: videoCreateStartedAt, + } + if err := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err != nil { + reqLog.Warn("grok_media.store_video_pending_billing_failed_retrying", + zap.Int64("account_id", account.ID), + zap.String("request_id", result.ResponseID), + zap.Error(err), + ) + if err2 := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err2 != nil { + // Response body may already be committed; completion path will fail-closed + // when pending is still missing and status cannot price duration. + reqLog.Error("grok_media.store_video_pending_billing_failed", + zap.Int64("account_id", account.ID), + zap.String("request_id", result.ResponseID), + zap.Error(err2), + ) + } + } } - if shouldRecordGrokMediaUsage(endpoint, requestModel) { + // Status poll OR content download can observe official done+video.url. + // Both paths share the same claim key so the customer is charged once. + if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent { + taskID := strings.TrimSpace(requestID) + if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil { + recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID) + } + } else if shouldRecordGrokMediaUsage(endpoint, requestModel, result) { recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID) } reqLog.Debug("grok_media.request_completed", @@ -446,8 +499,147 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul return account.GetMappedModel(routingModel) } -func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool { - return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != "" +func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool { + switch endpoint { + case service.GrokMediaEndpointVideosGenerations, + service.GrokMediaEndpointVideosEdits, + service.GrokMediaEndpointVideosExtensions: + return true + default: + return false + } +} + +// shouldRecordGrokMediaUsage gates usage writes for immediate (image) generation. +// Async video create never bills here — status polling does on official +// status=done with video.url (docs.x.ai Video Generation). +// Status/content polls, empty model, and failed generations with zero billable +// image units never bill via this helper. +func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string, result *service.OpenAIForwardResult) bool { + if result == nil { + return false + } + if isGrokVideoCreateEndpoint(endpoint) || endpoint.IsVideoLookupRequest() { + return false + } + if !endpoint.IsGenerationRequest() || strings.TrimSpace(requestModel) == "" { + return false + } + return result.ImageCount > 0 +} + +// prepareGrokVideoCompletionBilling claims one-shot billing for official done+video.url +// observations (status poll or content download). Duration/model prefer status body; +// resolution uses create-time request (status response does not document resolution). +func prepareGrokVideoCompletionBilling( + ctx context.Context, + h *OpenAIGatewayHandler, + reqLog *zap.Logger, + apiKey *service.APIKey, + subject middleware2.AuthSubject, + taskRequestID string, + statusResult *service.OpenAIForwardResult, +) *service.OpenAIForwardResult { + if h == nil || h.gatewayService == nil || apiKey == nil || statusResult == nil { + return nil + } + // Forward already set VideoCount only when status=done && video.url (official). + if statusResult.VideoCount <= 0 { + return nil + } + taskRequestID = strings.TrimSpace(firstNonEmptyString(taskRequestID, statusResult.ResponseID)) + if taskRequestID == "" { + return nil + } + // Load create-time snapshot before claim so we can fail-closed without burning the claim + // when Redis lost pending and status cannot price the job. + pending, loadErr := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskRequestID, subject.UserID, apiKey.ID) + if loadErr != nil { + reqLog.Warn("grok_media.video_pending_billing_load_failed", zap.String("request_id", taskRequestID), zap.Error(loadErr)) + } + if pending == nil { + // Status omits resolution; without pending we would silently default to 480p and underbill. + // Allow billing only when official status carries duration (still may default resolution). + if statusResult.VideoDurationSeconds <= 0 { + reqLog.Error("grok_media.video_billing_skipped_missing_pending", + zap.String("request_id", taskRequestID), + zap.String("reason", "no create-time snapshot and status has no video.duration"), + ) + return nil + } + reqLog.Error("grok_media.video_billing_without_pending", + zap.String("request_id", taskRequestID), + zap.Int("status_duration_seconds", statusResult.VideoDurationSeconds), + zap.String("note", "resolution falls back to default 480p; investigate pending store failures"), + ) + } + claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskRequestID, subject.UserID, apiKey.ID) + if err != nil { + reqLog.Warn("grok_media.video_billing_claim_failed", zap.String("request_id", taskRequestID), zap.Error(err)) + return nil + } + if !claimed { + reqLog.Debug("grok_media.video_billing_already_claimed", zap.String("request_id", taskRequestID)) + return nil + } + // Re-merge with pending: resolution is request-only; model/duration fill gaps. + merged := *statusResult + if pending != nil { + if strings.TrimSpace(merged.Model) == "" { + merged.Model = firstNonEmptyString(pending.BillingModel, pending.Model, pending.OriginalModel) + } + if strings.TrimSpace(merged.BillingModel) == "" { + merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model, merged.Model) + } + if strings.TrimSpace(merged.UpstreamModel) == "" { + merged.UpstreamModel = pending.UpstreamModel + } + // Official status omits resolution — always prefer create request. + if strings.TrimSpace(pending.VideoResolution) != "" { + merged.VideoResolution = pending.VideoResolution + } + if merged.VideoDurationSeconds <= 0 { + merged.VideoDurationSeconds = pending.VideoDurationSeconds + } + if strings.TrimSpace(merged.ResponseID) == "" { + merged.ResponseID = taskRequestID + } + } + if strings.TrimSpace(merged.Model) == "" { + merged.Model = "grok-imagine-video" + } + if strings.TrimSpace(merged.BillingModel) == "" { + merged.BillingModel = merged.Model + } + // Always force durable task id so usage_billing_dedup survives multi-poll + + // context-local request ids (do not prefer empty-only fill). + merged.RequestID = service.StableGrokVideoBillingRequestID(firstNonEmptyString(merged.ResponseID, taskRequestID)) + merged.ResponseID = firstNonEmptyString(merged.ResponseID, taskRequestID) + merged.VideoCount = 1 + // Pure video: do not keep legacy ImageCount (avoids image-path heuristics). + merged.ImageCount = 0 + // Official default resolution is 480p when the create request omitted it. + merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution) + // Official default duration is 8s when neither status nor create provided it. + merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds) + // E2E latency for async video: create accept → this discovery of done+url. + // Bill on discovery (status/content), not after further client polls; duration + // must not be only the single discovery hop (~hundreds of ms). + if pending != nil { + if e2e := service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()); e2e > 0 { + merged.Duration = e2e + } + } + return &merged +} + +func firstNonEmptyString(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" } func recordGrokMediaUsage( @@ -480,6 +672,18 @@ func recordGrokMediaUsage( OriginalModel: clientRequestedModel(c, requestModel), ChannelMappedModel: requestModel, } + // Async video: force durable task request id and release claim if billing fails. + videoTaskID := "" + if result != nil && result.VideoCount > 0 { + videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID)) + if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" { + result.RequestID = stable + } + // Prefer task id hash for payload fingerprint stability across status/content. + if len(body) == 0 && videoTaskID != "" { + payloadForHash = []byte(videoTaskID) + } + } h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: result, @@ -497,6 +701,14 @@ func recordGrokMediaUsage( SessionID: sessionID, ChannelUsageFields: channelUsageFields, }); err != nil { + if videoTaskID != "" { + if releaseErr := h.gatewayService.ReleaseGrokVideoBilling(ctx, videoTaskID, subject.UserID, apiKey.ID); releaseErr != nil { + reqLog.Warn("grok_media.video_billing_claim_release_failed", + zap.String("request_id", videoTaskID), + zap.Error(releaseErr), + ) + } + } logger.L().With( zap.String("component", "handler.openai_gateway.grok_media"), zap.Int64("user_id", subject.UserID), diff --git a/backend/internal/handler/grok_media_test.go b/backend/internal/handler/grok_media_test.go index fbd2af820e..5d5deec22e 100644 --- a/backend/internal/handler/grok_media_test.go +++ b/backend/internal/handler/grok_media_test.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/service" @@ -41,13 +42,13 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) { want: true, }, { - name: "video generation records usage", + name: "video generation defers usage until status", endpoint: service.GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", - want: true, + want: false, }, { - name: "video status skips empty model usage", + name: "video status skips immediate helper (status path claims separately)", endpoint: service.GrokMediaEndpointVideoStatus, model: "", want: false, @@ -68,7 +69,18 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model)) + // Nil result must never bill. + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, nil)) + // Immediate helper only bills image generation (async video bills on status). + result := &service.OpenAIForwardResult{ImageCount: 1, VideoCount: 0} + if tt.endpoint.IsGenerationRequest() && !isGrokVideoCreateEndpoint(tt.endpoint) && strings.TrimSpace(tt.model) != "" { + require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result)) + } else { + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result)) + } + // Zero billable units never bill even for generation + model. + empty := &service.OpenAIForwardResult{} + require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, empty)) }) } } diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 9334ffe26a..359aff0071 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -53,6 +53,7 @@ type Handlers struct { Subscription *SubscriptionHandler Announcement *AnnouncementHandler ChannelMonitor *ChannelMonitorUserHandler + ChannelMonitorV2 *ChannelMonitorV2Handler Admin *AdminHandlers Gateway *GatewayHandler OpenAIGateway *OpenAIGatewayHandler diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 577b1d85e8..0bf294d443 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -54,19 +54,23 @@ type modelPlazaModel struct { // modelPlazaGroup 广场分组条目(白名单字段)。 type modelPlazaGroup struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Platform string `json:"platform"` - SubscriptionType string `json:"subscription_type"` - RateMultiplier float64 `json:"rate_multiplier"` - UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"` - PeakRateEnabled bool `json:"peak_rate_enabled"` - PeakStart string `json:"peak_start"` - PeakEnd string `json:"peak_end"` - PeakRateMultiplier float64 `json:"peak_rate_multiplier"` - IsExclusive bool `json:"is_exclusive"` - Models []modelPlazaModel `json:"models"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + SubscriptionType string `json:"subscription_type"` + RateMultiplier float64 `json:"rate_multiplier"` + UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` + // 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier, + // 不取分组/用户专属倍率。 + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + Models []modelPlazaModel `json:"models"` } // modelPlazaResponse 广场页响应。 @@ -164,18 +168,20 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo }) } 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, - 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, + Models: models, } if rate, ok := userRates[g.ID]; ok { dto.UserRateMultiplier = &rate diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 1922473e3c..a7fc291ab8 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -91,6 +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", } { _, exists := decoded[key] require.Truef(t, exists, "plaza group DTO must expose %q", key) diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index c76b7d9fa5..c5766e5669 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -107,12 +107,18 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String()) sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID) + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) var lastFailoverErr *service.UpstreamFailoverError switchCount := 0 var oauth429FailoverState service.OpenAIOAuth429FailoverState routingStart := time.Now() + // 分组利润控制:alpha search 文本入口请求级装门并固定 pricingAt + //(记录路径经 service.OpenAIPricingAtFromContext 从请求 ctx 回读)。 + asPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) + c.Request = c.Request.WithContext(asPricingCtx) + for { selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( c.Request.Context(), @@ -151,8 +157,16 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { account := selection.Account setOpsSelectedAccount(c, account.ID, account.Platform) - accountRelease, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog) - if !acquired { + accountRelease, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) @@ -255,6 +269,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage( QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel), + PricingAt: service.OpenAIPricingAtFromContext(c.Request.Context()), }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.alpha_search"), diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 39c12253b1..42dc13112a 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -80,10 +80,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } - if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { - body = cappedBody - } + if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) if !ok { @@ -144,11 +142,16 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { 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 + // 分组利润控制:chat completions 文本入口请求级装门并固定 pricingAt。 + ccPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) + c.Request = c.Request.WithContext(ccPricingCtx) + for { if failoverClientGone(c) { return @@ -207,8 +210,16 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { _ = scheduleDecision setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) - if !acquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } @@ -277,16 +288,18 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { retryLimit := account.GetPoolModeRetryCount() if sameAccountRetryCount[account.ID] < retryLimit { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_chat_completions.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 <-c.Request.Context().Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } @@ -358,6 +371,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + PricingAt: pricingAt, CyberBlocked: cyberBlocked, }); err != nil { logger.L().With( diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 3ed44b12b8..92008c59c2 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -107,6 +107,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { return } + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) var lastFailoverErr *service.UpstreamFailoverError switchCount := 0 @@ -116,6 +117,10 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { } routingStart := time.Now() + // 分组利润控制:embeddings 文本入口请求级装门并固定 pricingAt。 + embPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) + c.Request = c.Request.WithContext(embPricingCtx) + for { selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( c.Request.Context(), @@ -165,8 +170,16 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { account := selection.Account setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog) - if !accountAcquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } @@ -260,6 +273,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + PricingAt: pricingAt, }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.embeddings"), diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 2d642e6c79..ac6deda4aa 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -143,6 +143,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { } requestStart := time.Now() + // count_tokens 不计费:显式豁免利润门,避免高倍率账号池被门排除后连 + // token 计数都返回 no available accounts。 + c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context())) sessionHash := h.gatewayService.GenerateSessionHash(c, body) currentRoutingModel := routingModel if preferredMappedModel != "" { diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 773a87a86e..26b5dd03c5 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -312,10 +313,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } - if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed { - body = cappedBody - } + if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed { + body = cappedBody } reqStream, ok := parseOpenAICompatibleStream(body) @@ -426,6 +425,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { maxAccountSwitches := h.maxAccountSwitches switchCount := 0 firstOutputTimeoutSwitchCount := 0 + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError @@ -439,6 +439,13 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。 requiredCapability := openAIResponsesRequiredCapability(imageIntent, requestPlatform) + // 分组利润控制:请求级装配定价上下文——pricingAt 固定本请求的 + // D 与计费高峰因子,选号、槽位终检与全部 failover 重入共用同一门与阈值。 + // 生图意图只影响能力路由与图片计费,不关门:混合 /v1/responses 请求的 + // token 计费部分仍受利润门保护,独立图片/视频端点才在门外。 + pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) + c.Request = c.Request.WithContext(pricingCtx) + for { // Streaming Forward intentionally detaches the upstream request so usage can // be drained after a disconnect. Re-check the client context before every @@ -516,8 +523,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { reqLog.Debug("openai.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name)) setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) - if !acquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错; + // 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } @@ -594,16 +610,18 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { retryLimit := account.GetPoolModeRetryCount() if sameAccountRetryCount[account.ID] < retryLimit { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai.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 <-c.Request.Context().Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } @@ -696,6 +714,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + PricingAt: pricingAt, CyberBlocked: cyberBlocked, }); err != nil { logger.L().With( @@ -932,6 +951,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } + bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() @@ -988,12 +1008,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { 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 effectiveMappedModel := preferredMappedModel + // 分组利润控制:Messages 文本入口同样请求级装门并固定 pricingAt。 + msgPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID) + c.Request = c.Request.WithContext(msgPricingCtx) + for { if failoverClientGone(c) { return @@ -1058,8 +1083,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { _ = scheduleDecision setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) - if !acquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错; + // 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } @@ -1126,16 +1160,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { retryLimit := account.GetPoolModeRetryCount() if sameAccountRetryCount[account.ID] < retryLimit { sameAccountRetryCount[account.ID]++ + retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID]) reqLog.Warn("openai_messages.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 <-c.Request.Context().Done(): return - case <-time.After(sameAccountRetryDelay): + case <-time.After(retryDelay): } continue } @@ -1208,6 +1244,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel), + PricingAt: pricingAt, CyberBlocked: cyberBlocked, }); err != nil { logger.L().With( @@ -1348,6 +1385,74 @@ func (h *OpenAIGatewayHandler) acquireResponsesUserSlot( return wrapReleaseOnDone(ctx, userReleaseFunc), true } +// openAISlotAcquireResult 是账号槽位获取的三态结果。 +type openAISlotAcquireResult int + +const ( + openAISlotAcquireOK openAISlotAcquireResult = iota + // openAISlotAcquireFailed:错误响应已写出,调用方直接 return。 + openAISlotAcquireFailed + // openAISlotAcquireProfitVetoed:槽位获取成功后利润终检否决。槽位已释放、 + // 未写任何响应;调用方应经 recordOpenAIProfitVeto 把该账号加入本请求排除集 + // 后重新选号,全池耗尽由下一轮选号返回标准 no available accounts。 + openAISlotAcquireProfitVetoed +) + +// openAIWSTurnPricing 持有 WebSocket 连接内「当前 turn」的计费定价时刻。 +// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 +// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 +// +// 零值语义(重要):ws_v2 passthrough ingress 只实现了 AfterTurn,没有任何 +// turn 起始回调,BeforeTurn 永远不会被调用。此时本值保持零,RecordUsage 经 +// openAIUsagePricingAt 回退到记录时刻——与引入分组利润控制前的基线一致。 +// 绝不能用建连时刻初始化:那会把透传连接的所有 turn 钉死在建连时的高峰因子, +// 客户端只要峰前一分钟建连并保活,整条连接就能全程按谷价结算,正是利润控制 +// 想堵的漏洞。透传 ingress 目前不做 turn 级利润复核,只有建连时的准入门。 +type openAIWSTurnPricing struct { + mu sync.Mutex + at time.Time +} + +func (p *openAIWSTurnPricing) freeze(at time.Time) { + p.mu.Lock() + p.at = at + p.mu.Unlock() +} + +func (p *openAIWSTurnPricing) current() time.Time { + p.mu.Lock() + defer p.mu.Unlock() + return p.at +} + +// recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号 +// 加入本请求排除集并递增否决计数。返回 false 表示否决次数已达 +// maxProfitVetoAttempts,调用方必须停止重选并按「无可用账号」终止。 +// +// OpenAI 路径用的是各自的 failedAccountIDs map + for 循环(不是 FailoverState), +// 这里用一个独立计数器复用同一上限语义。上限是必需的:WaitPlan 分支先阻塞 +// 排队(sticky 45s / fallback 30s)拿到槽位才终检,无上限重选会把单次请求的 +// 延迟放大到 N × WaitPlan.Timeout。 +func recordOpenAIProfitVeto(failedAccountIDs map[int64]struct{}, accountID int64, vetoCount *int) bool { + failedAccountIDs[accountID] = struct{}{} + *vetoCount++ + return *vetoCount < maxProfitVetoAttempts +} + +// handleOpenAIProfitVetoExhausted 在利润否决预算耗尽时写出错误响应。 +// 与 acquireResponsesAccountSlot 内部的 no-available-accounts 失败分支同形, +// 保证同一调用方在两条路径上拿到一致的响应格式。 +func (h *OpenAIGatewayHandler) handleOpenAIProfitVetoExhausted( + c *gin.Context, + streamStarted bool, + reqLog *zap.Logger, + vetoCount int, +) { + reqLog.Warn("openai.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", vetoCount)) + markOpsRoutingCapacityLimited(c) + h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) +} + func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( c *gin.Context, groupID *int64, @@ -1356,22 +1461,41 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( reqStream bool, streamStarted *bool, reqLog *zap.Logger, -) (func(), bool) { +) (func(), openAISlotAcquireResult) { if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) - return nil, false + return nil, openAISlotAcquireFailed } - ctx := c.Request.Context() + // 终检与准入后绑定使用选号结果携带的门:composite 等跨分组调度解析出的 + // 门只存在于调度栈的局部 ctx,必须经选号结果重放到本函数的 ctx 上。 + ctx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) account := selection.Account if selection.Acquired { - return wrapReleaseOnDone(ctx, selection.ReleaseFunc), true + latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) + if vetoed { + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + return nil, openAISlotAcquireProfitVetoed + } + account = latest + selection.Account = latest + // 调度器已抢槽路径无门时由选号内部完成 eager 绑定;门下选号内部 + // 推迟绑定,这里在终检通过后补准入后绑定。 + if selection.ProfitGateActive() { + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { + reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + } + return wrapReleaseOnDone(ctx, selection.ReleaseFunc), openAISlotAcquireOK } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted) - return nil, false + return nil, openAISlotAcquireFailed } fastReleaseFunc, fastAcquired, err := h.concurrencyHelper.TryAcquireAccountSlot( @@ -1382,13 +1506,25 @@ 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) - return nil, false + return nil, openAISlotAcquireFailed } if fastAcquired { - if err := h.gatewayService.BindStickySession(ctx, groupID, sessionHash, account.ID); err != nil { - reqLog.Warn("openai.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + // 分组利润控制:快速抢槽成功后终检。选号与抢槽之间账号 + // 倍率可能刷新,越线则释放槽位交由调用方排除重选,不绑定粘连。 + latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) + if vetoed { + if fastReleaseFunc != nil { + fastReleaseFunc() + } + reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + return nil, openAISlotAcquireProfitVetoed } - return wrapReleaseOnDone(ctx, fastReleaseFunc), true + account = latest + selection.Account = latest + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { + reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + return wrapReleaseOnDone(ctx, fastReleaseFunc), openAISlotAcquireOK } canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(ctx, account.ID, selection.WaitPlan.MaxWaiting) @@ -1400,7 +1536,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted) - return nil, false + return nil, openAISlotAcquireFailed } accountWaitCounted := waitErr == nil && canWait @@ -1423,15 +1559,27 @@ 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) - return nil, false + return nil, openAISlotAcquireFailed } // Slot acquired: no longer waiting in queue. releaseWait() - if err := h.gatewayService.BindStickySession(ctx, groupID, sessionHash, account.ID); err != nil { - reqLog.Warn("openai.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + // 分组利润控制:WaitPlan 排队成功后终检。排队期间账号倍率 + // 可能上调,越线则释放槽位交由调用方排除重选,不绑定粘连。 + latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + return nil, openAISlotAcquireProfitVetoed } - return wrapReleaseOnDone(ctx, accountReleaseFunc), true + account = latest + selection.Account = latest + if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil { + reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } + return wrapReleaseOnDone(ctx, accountReleaseFunc), openAISlotAcquireOK } // ResponsesWebSocket handles OpenAI Responses API WebSocket ingress endpoint @@ -1468,7 +1616,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { reqLog.Info("openai.websocket_ingress_started") clientIP := ip.GetClientIP(c) userAgent := strings.TrimSpace(c.GetHeader("User-Agent")) - ctx := c.Request.Context() + clientLifecycleCtx := c.Request.Context() + ctx := clientLifecycleCtx maxIngressConnections := 0 if h.cfg != nil { maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey @@ -1667,6 +1816,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { ) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState @@ -1717,6 +1867,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { requiredCapability = service.OpenAIEndpointCapabilityResponses } + // 分组利润控制:WS 桥按连接装配定价上下文并装门(选号与抢槽共用该 + // ctx)。连接内不重选号,但每个 turn 开始经 BeforeTurn 重新冻结 pricingAt + // 并按最新门复核当前账号(准入与计费同源),峰前建连保活不能让后续 turn + // 继续按建连时刻的谷价计费。生图意图只影响能力路由与图片计费,不关门。 + // 建连时刻只用于选号/准入,不作为任何 turn 的计费定价时刻。 + wsPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(ctx, apiKey.GroupID) + ctx = wsPricingCtx + for { if ctx.Err() != nil { return @@ -1762,7 +1920,28 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if selection.WaitPlan != nil && selection.WaitPlan.MaxConcurrency > 0 { accountMaxConcurrency = selection.WaitPlan.MaxConcurrency } + // 终检、准入后绑定与后续 turn 级复核都使用选号结果携带的门(composite + // 等跨分组调度的门只存在于调度栈局部 ctx);准入成功后并入连接 ctx。 + admissionCtx := service.ContextWithSelectionProfitGate(ctx, selection) accountReleaseFunc := selection.ReleaseFunc + if selection.Acquired { + // 调度器已抢槽路径同样终检:选号与抢槽之间账号倍率可能刷新。 + latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if accountReleaseFunc != nil { + accountReleaseFunc() + } + reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount)) + closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") + return + } + continue + } + account = latest + selection.Account = latest + } if !selection.Acquired { if selection.WaitPlan == nil { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later") @@ -1782,11 +1961,30 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later") return } + // 分组利润控制:WS 快速抢槽成功后终检,越线则释放 + // 槽位、排除该账号重新选号,全池耗尽由下一轮选号关闭连接。 + latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account) + if vetoed { + if fastReleaseFunc != nil { + fastReleaseFunc() + } + reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount)) + closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account") + return + } + continue + } + account = latest + selection.Account = latest accountReleaseFunc = fastReleaseFunc } + // 准入完成:门并入连接 ctx,turn 级复核与 failover 重选共用。 + ctx = admissionCtx currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc) - if err := h.gatewayService.BindStickySession(ctx, apiKey.GroupID, sessionHash, account.ID); err != nil { - reqLog.Warn("openai.websocket_bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + 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)) } token, _, err := h.gatewayService.GetRequestCredential(ctx, c, account) @@ -1813,22 +2011,23 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { zap.Int("candidate_count", scheduleDecision.CandidateCount), ) - maxReasoningEffort := "" - var reasoningEffortMappings []service.ReasoningEffortMapping - if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI { - maxReasoningEffort = apiKey.Group.MaxReasoningEffort - reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings - } + maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey) var requestPayloadHash string // 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 的注释——绝不能用建连时刻初始化。 + var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ + ClientLifecycleContext: clientLifecycleCtx, InitialRequestModel: reqModel, MaxReasoningEffort: maxReasoningEffort, ReasoningEffortMappings: reasoningEffortMappings, BeforeRequest: func(turn int, payload []byte, originalModel string) error { + c.Set(securityAuditWSTurnContextKey, turn) if turn == 1 { return nil } @@ -1869,6 +2068,18 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if cyberBlockedThisConn { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } + // 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核 + // 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号, + // 无法中途换号)。本 turn 的准入与计费共用同一 pricingAt。 + turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(ctx, apiKey.GroupID) + if _, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(turnCtx, account); vetoed { + reqLog.Info("openai.websocket_turn_profit_vetoed", + zap.Int("turn", turn), + zap.Int64("account_id", account.ID), + zap.String("reason", reason)) + return service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "account is no longer eligible for this connection, please reconnect", nil) + } + turnPricing.freeze(turnAt) if turn == 1 { return nil } @@ -1967,6 +2178,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) + turnRecordPricingAt := turnPricing.current() cyberBlocked := service.GetOpsCyberPolicy(c) != nil h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ @@ -1984,6 +2196,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { QuotaPlatform: quotaPlatform, SessionID: sessionID, ChannelUsageFields: turnUsageFields, + PricingAt: turnRecordPricingAt, CyberBlocked: cyberBlocked, }); err != nil { reqLog.Error("openai.websocket_record_usage_failed", @@ -2183,10 +2396,16 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas } task = wrapUsageRecordTaskContext(parent, task) if h.usageRecordWorkerPool != nil { - h.usageRecordWorkerPool.Submit(task) - return + if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped { + return + } + // 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。 + // 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。 + logger.L().With( + zap.String("component", "handler.openai_gateway.responses"), + ).Warn("openai.usage_record_task_stopped_sync_fallback") } - // 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。 + // 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() defer func() { @@ -2201,7 +2420,9 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas } func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(parent context.Context, result *service.OpenAIForwardResult, task service.UsageRecordTask) { - if result != nil && result.ImageCount > 0 { + // Money-critical bills never drop on pool overflow: media, search surcharge, voice. + if result != nil && (result.ImageCount > 0 || result.VideoCount > 0 || + result.SearchCount > 0 || result.WebSearchCalls > 0 || result.AudioUsage != nil) { h.submitMandatoryUsageRecordTask(parent, task) return } @@ -2214,7 +2435,7 @@ func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Con } task = wrapUsageRecordTaskContext(parent, task) if h.usageRecordWorkerPool != nil { - if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDropped { + if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() { return } logger.L().With( @@ -2482,7 +2703,11 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream imageKeepalivePaddingOnly = adjustedSize < 0 imageKeepaliveResponseWritten = adjustedSize >= 0 } - if service.IsResponseCommitted(c) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) { + compactKeepaliveHasMeaningfulOutput := compactKeepaliveCommitted && service.OpenAICompactKeepaliveAdjustedWrittenSize(c) > 0 + // Compact keepalive may have committed 200 headers without writing a + // semantic SSE event. In that case the Responses stream still needs its + // protocol-correct terminal response.failed event. + if (service.IsResponseCommitted(c) && (!compactKeepaliveCommitted || compactKeepaliveHasMeaningfulOutput)) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) { return false } if c.Writer.Written() && !imageKeepalivePaddingOnly { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 59925d9fa0..6e8a687d77 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" coderws "github.com/coder/websocket" @@ -334,6 +335,26 @@ func TestOpenAIEnsureForwardErrorResponse_AfterDeltaAppendsSingleValidResponseFa require.Equal(t, 1, errorEvents) } +func TestOpenAIEnsureForwardErrorResponse_CompactKeepaliveOnlyWritesResponseFailed(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil) + service.MarkOpenAICompactClientStream(c) + + stop := service.StartOpenAICompactSSEKeepalive(c, 5*time.Millisecond) + defer stop() + before := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) + require.Eventually(t, c.Writer.Written, time.Second, time.Millisecond) + require.Equal(t, before, service.OpenAICompactKeepaliveAdjustedWrittenSize(c)) + + h := &OpenAIGatewayHandler{} + require.True(t, h.ensureForwardErrorResponse(c, false)) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "event: response.failed\n") + require.NotContains(t, w.Body.String(), "event: error\n") +} + func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepaliveWritesSingleJSONFallback(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -643,6 +664,9 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { }) t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) { + original := xai.RuntimeModelMappingOptions() + t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) }) + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{EnableCrossClientMap: true}) apiKey := &service.APIKey{ Group: &service.Group{ Platform: service.PlatformGrok, @@ -1266,6 +1290,7 @@ func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T nil, nil, nil, + nil, ) decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{ UserID: 1, @@ -1407,7 +1432,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { require.Equal(t, "gpt-5.6-terra", *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.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + 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") } @@ -1477,7 +1502,7 @@ func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) require.NotNil(t, got.logs[1].ModelMappingChain) require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain) - require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12, "the next turn must use the updated channel mapping") } @@ -1506,7 +1531,7 @@ func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesReques require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel) require.NotNil(t, got.logs[1].ModelMappingChain) require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain) - require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12, + require.InDelta(t, 16e-6, got.logs[1].TotalCost, 1e-12, "BillingModelSourceRequested must use the client model before channel mapping") } @@ -1842,6 +1867,40 @@ func (u *openAIHTTPPassthroughAuthFailoverUpstream) calls() []int64 { return append([]int64(nil), u.accountIDs...) } +type openAIHTTPPassthroughSSERateLimitUpstream struct { + service.HTTPUpstream + mu sync.Mutex + accountIDs []int64 +} + +func (u *openAIHTTPPassthroughSSERateLimitUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) { + u.mu.Lock() + u.accountIDs = append(u.accountIDs, accountID) + u.mu.Unlock() + body := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_rate_limited"}}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_rate_limited","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`, + "", + }, "\n") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "Retry-After": []string{"1"}, + }, + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +func (u *openAIHTTPPassthroughSSERateLimitUpstream) calls() []int64 { + u.mu.Lock() + defer u.mu.Unlock() + return append([]int64(nil), u.accountIDs...) +} + func (s *openAIWSFailoverHandlerAccountRepoStub) ListSchedulableByPlatform(ctx context.Context, platform string) ([]service.Account, error) { out := make([]service.Account, 0, len(s.accounts)) for _, account := range s.accounts { @@ -2144,6 +2203,88 @@ func TestOpenAIResponses_APIKeyPassthroughPoolAuthFailureRetriesThenSwitchesToHe } } +func TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(4204) + accounts := []service.Account{ + { + ID: 9912, Name: "pool-sse-rate-limit", Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Priority: 1, + Credentials: map[string]any{ + "api_key": "sk-pool", + "base_url": "https://api.example.test", + "pool_mode": true, + "pool_mode_retry_count": float64(1), + "pool_mode_retry_status_codes": []any{float64(http.StatusTooManyRequests)}, + }, + Extra: map[string]any{"openai_passthrough": true}, + }, + } + cfg := &config.Config{RunMode: config.RunModeSimple} + cfg.Default.RateMultiplier = 1 + cfg.Security.URLAllowlist.Enabled = false + cfg.Gateway.MaxAccountSwitches = 1 + + accountRepo := &openAIWSFailoverHandlerAccountRepoStub{accounts: accounts} + upstream := &openAIHTTPPassthroughSSERateLimitUpstream{} + billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(billingCacheSvc.Stop) + gatewaySvc := service.NewOpenAIGatewayService( + accountRepo, + nil, + nil, + nil, + nil, + nil, + nil, + cfg, + nil, + nil, + service.NewBillingService(cfg, nil), + nil, + billingCacheSvc, + upstream, + &service.DeferredService{}, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + ) + h := NewOpenAIGatewayHandler( + gatewaySvc, + service.NewConcurrencyService(nil), + billingCacheSvc, + service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg), + nil, + nil, + nil, + nil, + cfg, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(`{"model":"gpt-5.6-sol","input":"hello","stream":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + ID: 1804, GroupID: &groupID, + User: &service.User{ID: 1704, Status: service.StatusActive}, + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, Status: service.StatusActive}, + }) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1704, Concurrency: 0}) + + h.Responses(c) + + require.Equal(t, []int64{9912, 9912}, upstream.calls()) + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.Equal(t, "1", rec.Header().Get("Retry-After")) + require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) + require.Equal(t, "Upstream rate limit exceeded, please retry later", gjson.GetBytes(rec.Body.Bytes(), "error.message").String()) +} + func TestOpenAIResponsesWebSocket_FailoverOnUpstreamUsageLimitEvent(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 72e2f462ad..da41bf6aa3 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -144,10 +144,11 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body) - requestCtx := service.WithOpenAIImageGenerationIntent(c.Request.Context()) + requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context())) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 + profitVetoCount := 0 failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError @@ -221,8 +222,16 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { reqLog.Debug("openai.images.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name)) setOpsSelectedAccount(c, account.ID, account.Platform) - accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, parsed.Stream, &streamStarted, reqLog) - if !acquired { + accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, parsed.Stream, &streamStarted, reqLog) + if slotResult == openAISlotAcquireProfitVetoed { + // Images 调度不装利润门,此分支实际不可达;防御性排除重选并受同一否决上限约束。 + if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) { + h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount) + return + } + continue + } + if slotResult != openAISlotAcquireOK { return } diff --git a/backend/internal/handler/openai_profit_slot_recheck_test.go b/backend/internal/handler/openai_profit_slot_recheck_test.go new file mode 100644 index 0000000000..b40fbc4e2c --- /dev/null +++ b/backend/internal/handler/openai_profit_slot_recheck_test.go @@ -0,0 +1,150 @@ +//go:build unit + +package handler + +// 槽位终检与生图跳门回归(handler 半程): +// - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选, +// 不写响应、不绑定粘连; +// - openAIResponsesRequiredCapability 的生图意图映射钉死(scheduler 的 +// 跳门条件依赖 CapabilityResponses ⇔ 显式生图意图这一耦合)。 + +import ( + "context" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type profitCountingConcurrencyCache struct { + fakeConcurrencyCache + accountReleases atomic.Int64 +} + +func (c *profitCountingConcurrencyCache) ReleaseAccountSlot(context.Context, int64, string) error { + c.accountReleases.Add(1) + return nil +} + +func profitSlotTestAccount(id int64, rate float64) *service.Account { + now := time.Now() + return &service.Account{ + ID: id, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 2, + RateMultiplier: &rate, + Extra: map[string]any{ + "upstream_billing_probe": map[string]any{ + "status": service.UpstreamBillingProbeStatusOK, + "received_at": now.Add(-time.Minute), + "fresh_until": now.Add(30 * time.Minute), + "data": map[string]any{ + "billing_scope": "token", + "resolved_rate_multiplier": rate, + "peak_rate_enabled": false, + }, + }, + }, + } +} + +func profitSlotTestContext(t *testing.T, gw *service.OpenAIGatewayService, groupID int64, suppress bool) context.Context { + t.Helper() + group := &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: 1.0, + SubscriptionType: service.SubscriptionTypeStandard, + ProfitControlEnabled: true, + ProfitMinMargin: 0.5, + } + base := context.WithValue(context.Background(), ctxkey.Group, group) + if suppress { + base = service.WithOpenAIProfitControlSuppressed(base) + } + ctx, pricingAt := gw.WithOpenAIRequestPricingContext(base, &groupID) + require.False(t, pricingAt.IsZero()) + return ctx +} + +func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { + gin.SetMode(gin.TestMode) + gw := &service.OpenAIGatewayService{} + groupID := int64(50) + + newHandler := func(cache *profitCountingConcurrencyCache) *OpenAIGatewayHandler { + return &OpenAIGatewayHandler{ + gatewayService: gw, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatClaude, 0), + } + } + newSelection := func(account *service.Account) *service.AccountSelectionResult { + return &service.AccountSelectionResult{ + Account: account, + Acquired: false, + WaitPlan: &service.AccountWaitPlan{AccountID: account.ID, MaxConcurrency: 2, Timeout: time.Second, MaxWaiting: 2}, + } + } + + t.Run("veto releases slot and requests reschedule without writing response", func(t *testing.T) { + cache := &profitCountingConcurrencyCache{} + h := newHandler(cache) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false)) + streamStarted := false + + release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(1, 0.8)), false, &streamStarted, zap.NewNop()) + require.Equal(t, openAISlotAcquireProfitVetoed, result) + require.Nil(t, release) + require.Zero(t, w.Body.Len(), "利润终检否决不得写出任何响应") + require.Equal(t, int64(1), cache.accountReleases.Load(), "否决后必须立即释放已获取的槽位") + }) + + t.Run("qualifying account acquires normally", func(t *testing.T) { + cache := &profitCountingConcurrencyCache{} + h := newHandler(cache) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false)) + streamStarted := false + + release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(2, 0.3)), false, &streamStarted, zap.NewNop()) + require.Equal(t, openAISlotAcquireOK, result) + require.NotNil(t, release) + release() + }) + + t.Run("image intent suppression keeps official behavior", func(t *testing.T) { + cache := &profitCountingConcurrencyCache{} + h := newHandler(cache) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, true)) + streamStarted := false + + release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(3, 0.8)), false, &streamStarted, zap.NewNop()) + require.Equal(t, openAISlotAcquireOK, result, "生图意图跳门:过贵账号照常获取(图片边界不装门)") + require.NotNil(t, release) + release() + }) +} + +// scheduler 跳门条件依赖"CapabilityResponses 仅在显式生图意图时被要求"这一 +// 映射;后续若扩展该 capability 的用途,本测试失败提示同步收窄跳门条件。 +func TestOpenAIResponsesRequiredCapabilityPinsImageIntentMapping(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)) +} diff --git a/backend/internal/handler/openai_profit_veto_budget_test.go b/backend/internal/handler/openai_profit_veto_budget_test.go new file mode 100644 index 0000000000..60e4871121 --- /dev/null +++ b/backend/internal/handler/openai_profit_veto_budget_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRecordOpenAIProfitVetoBounded 钉死 OpenAI 侧选号循环的利润否决预算: +// 账号被加入排除集,且第 maxProfitVetoAttempts 次否决返回 false 要求终止。 +// +// 上限是必需的:acquireResponsesAccountSlot 的 WaitPlan 分支先阻塞排队 +// (sticky 45s / fallback 30s)拿到槽位才做利润终检,无上限重选会把单次请求 +// 的延迟放大到 N × WaitPlan.Timeout。 +func TestRecordOpenAIProfitVetoBounded(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + + for i := int64(1); i < int64(maxProfitVetoAttempts); i++ { + require.True(t, recordOpenAIProfitVeto(failed, i, &count), "第 %d 次否决应允许继续重选", i) + require.Contains(t, failed, i, "否决的账号必须进入本请求排除集") + } + + require.False(t, + recordOpenAIProfitVeto(failed, int64(maxProfitVetoAttempts), &count), + "第 %d 次否决应耗尽预算并要求终止", maxProfitVetoAttempts) + require.Equal(t, maxProfitVetoAttempts, count) + require.Len(t, failed, maxProfitVetoAttempts) +} + +// TestOpenAIProfitVetoLoopTerminates 模拟「选号 → 抢槽 → 利润终检否决 → 重选」 +// 循环:整池越线时必须在常数步内终止,而不是把候选池逐个排队一遍。 +func TestOpenAIProfitVetoLoopTerminates(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + terminated := false + iterations := 0 + + // 候选池远大于否决上限:无上限时会对每个账号各排队一次。 + for accountID := int64(1); accountID <= 500; accountID++ { + iterations++ + if _, excluded := failed[accountID]; excluded { + continue + } + if !recordOpenAIProfitVeto(failed, accountID, &count) { + terminated = true + break + } + } + + require.True(t, terminated, "整池越线时循环必须由否决预算终止") + require.Equal(t, maxProfitVetoAttempts, iterations) +} + +// TestProfitVetoBudgetSharedWithFailoverState 钉死两条路径(FailoverState 与 +// OpenAI 独立计数器)使用同一上限语义,避免日后单边漂移。 +func TestProfitVetoBudgetSharedWithFailoverState(t *testing.T) { + fs := NewFailoverState(10, false) + failed := make(map[int64]struct{}) + count := 0 + + var fsStoppedAt, openAIStoppedAt int + for i := int64(1); i <= int64(maxProfitVetoAttempts)+5; i++ { + if fsStoppedAt == 0 && fs.RecordProfitVeto(i) == FailoverExhausted { + fsStoppedAt = fs.ProfitVetoCount() + } + if openAIStoppedAt == 0 && !recordOpenAIProfitVeto(failed, i, &count) { + openAIStoppedAt = count + } + } + + require.Equal(t, maxProfitVetoAttempts, fsStoppedAt) + require.Equal(t, fsStoppedAt, openAIStoppedAt, "两条路径的利润否决上限必须一致") +} diff --git a/backend/internal/handler/openai_ws_turn_pricing_test.go b/backend/internal/handler/openai_ws_turn_pricing_test.go new file mode 100644 index 0000000000..b9bb204fd6 --- /dev/null +++ b/backend/internal/handler/openai_ws_turn_pricing_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "testing" + "time" + + "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 回退记录时刻") +} + +// TestOpenAIWSTurnPricingFreezePerTurn 钉死每个 turn 的 BeforeTurn 都会覆盖 +// 上一个 turn 的定价时刻:长连接跨峰谷时后续 turn 不得沿用旧时刻。 +func TestOpenAIWSTurnPricingFreezePerTurn(t *testing.T) { + var p openAIWSTurnPricing + turn1 := time.Now().Add(-time.Hour) + turn2 := time.Now() + + p.freeze(turn1) + require.Equal(t, turn1, p.current()) + + p.freeze(turn2) + require.Equal(t, turn2, p.current(), "后续 turn 必须使用自己的定价时刻") +} diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index 8ddb16e4b3..5691f704f2 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -10,6 +10,7 @@ import ( "strings" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,6 +46,13 @@ type passkeyFinishRequest struct { Credential json.RawMessage `json:"credential" binding:"required"` } +type passkeyBeginLoginRequest struct { + // TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名) + TurnstileToken string `json:"turnstile_token"` + TencentCaptchaTicket string `json:"tencent_captcha_ticket"` + TencentCaptchaRandstr string `json:"tencent_captcha_randstr"` +} + type passkeyRenameRequest struct { Name string `json:"name" binding:"required"` } @@ -70,6 +78,16 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) { if !h.requirePasskeysEnabled(c) { return } + var req passkeyBeginLoginRequest + _ = c.ShouldBindJSON(&req) + if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{ + TurnstileToken: req.TurnstileToken, + TencentTicket: req.TencentCaptchaTicket, + TencentRandstr: req.TencentCaptchaRandstr, + }, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } assertion, token, err := h.passkeys.BeginLogin(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/passkey_handler_test.go b/backend/internal/handler/passkey_handler_test.go index 0d3b57fae4..961c0cdc41 100644 --- a/backend/internal/handler/passkey_handler_test.go +++ b/backend/internal/handler/passkey_handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,8 +16,30 @@ import ( ) type passkeySwitchSettingRepo struct { - value string - err error + value string + values map[string]string + err error +} + +type passkeyCaptchaVerifierStub struct { + calls int + proof service.TencentCaptchaProof +} + +func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) { + s.calls++ + s.proof = proof + return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil +} + +type passkeyBeginSessionStoreStub struct { + service.PasskeySessionStore + storeCalls int +} + +func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) { + s.storeCalls++ + return "passkey-session", nil } func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) { @@ -27,7 +50,10 @@ func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, er } func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil } func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { - return map[string]string{}, nil + if r.err != nil { + return nil, r.err + } + return r.values, nil } func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil @@ -87,6 +113,74 @@ func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) { require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED") } +func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) { + t.Helper() + cfg := &config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "sub2api.example.com", + RPOrigins: []string{"https://sub2api.example.com"}, + }} + repo := &passkeySwitchSettingRepo{ + value: "true", + values: map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaAppSecretKey: "app-secret", + service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }, + } + settings := service.NewSettingService(repo, cfg) + verifier := &passkeyCaptchaVerifierStub{} + authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil) + authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier)) + sessions := &passkeyBeginSessionStoreStub{} + passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil) + require.NoError(t, err) + return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions +} + +func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/passkey/login/begin", + strings.NewReader(body), + ) + ginContext.Request.Header.Set("Content-Type", "application/json") + return ginContext, recorder +} + +func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier, sessions := newTencentProtectedPasskeyHandler(t) + ginContext, recorder := newPasskeyBeginLoginContext(`{}`) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED") + require.Zero(t, verifier.calls) + require.Zero(t, sessions.storeCalls) +} + +func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, verifier, sessions := newTencentProtectedPasskeyHandler(t) + ginContext, recorder := newPasskeyBeginLoginContext( + `{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`, + ) + + handler.BeginLogin(ginContext) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, verifier.calls) + require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof) + require.Equal(t, 1, sessions.storeCalls) +} + func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) { gin.SetMode(gin.TestMode) handler := NewPasskeyHandler(nil, nil, nil) diff --git a/backend/internal/handler/security_audit_helper.go b/backend/internal/handler/security_audit_helper.go index eab6ab340f..210c84976e 100644 --- a/backend/internal/handler/security_audit_helper.go +++ b/backend/internal/handler/security_audit_helper.go @@ -1,6 +1,7 @@ package handler import ( + "crypto/sha256" "net/http" "strings" @@ -12,6 +13,15 @@ import ( ) const securityAuditCompletedContextKey = "sub2api.security_audit.completed" +const securityAuditWSTurnContextKey = "sub2api.security_audit.ws_turn" +const securityAuditWSDedupeContextKey = "sub2api.security_audit.ws_dedupe" + +type securityAuditWSDedupeEntry struct { + stage string + turn int + bodyHash [sha256.Size]byte + decision securityaudit.Decision +} // cachesSecurityAuditCompletion reports whether a successful audit may be // reused for the rest of the gin request. WebSocket turns share one Context @@ -25,6 +35,15 @@ func cachesSecurityAuditCompletion(stage string) bool { } } +func isSecurityAuditWebSocketStage(stage string) bool { + switch strings.TrimSpace(stage) { + case "first_turn", "subsequent_turn": + return true + default: + return false + } +} + func (h *GatewayHandler) checkSecurityAudit(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol, model string, body []byte) *securityaudit.Decision { if h == nil { return nil @@ -76,6 +95,25 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya return &decision } request := buildSecurityAuditRequest(c, apiKey, subject, protocol, model, body, stage) + if isSecurityAuditWebSocketStage(request.Stage) { + if turnNo, ok := securityAuditWSTurn(c); ok { + bodyHash := sha256.Sum256(body) + if cached, exists := c.Get(securityAuditWSDedupeContextKey); exists { + if entry, ok := cached.(securityAuditWSDedupeEntry); ok && + entry.stage == request.Stage && entry.turn == turnNo && entry.bodyHash == bodyHash { + decision := entry.decision + return &decision + } + } + decision := coordinator.Check(c.Request.Context(), request) + if decision.Kind == securityaudit.DecisionAllow { + c.Set(securityAuditWSDedupeContextKey, securityAuditWSDedupeEntry{ + stage: request.Stage, turn: turnNo, bodyHash: bodyHash, decision: decision, + }) + } + return &decision + } + } if reqLog != nil { reqLog.Info("security_audit.gateway_check_start", zap.String("request_id", request.RequestID), zap.Int64("user_id", request.UserID), @@ -97,6 +135,15 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya return &decision } +func securityAuditWSTurn(c *gin.Context) (int, bool) { + turn, exists := c.Get(securityAuditWSTurnContextKey) + if !exists { + return 0, false + } + turnNo, ok := turn.(int) + return turnNo, ok +} + func buildSecurityAuditRequest(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol, model string, body []byte, stage string) securityaudit.Request { legacy := buildContentModerationInput(c, apiKey, subject, protocol, model, body) request := securityaudit.Request{ diff --git a/backend/internal/handler/security_audit_helper_test.go b/backend/internal/handler/security_audit_helper_test.go index fa3e0eeca7..f8a8b3c2b0 100644 --- a/backend/internal/handler/security_audit_helper_test.go +++ b/backend/internal/handler/security_audit_helper_test.go @@ -18,6 +18,9 @@ func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) { require.True(t, cachesSecurityAuditCompletion("")) require.False(t, cachesSecurityAuditCompletion("first_turn")) require.False(t, cachesSecurityAuditCompletion("subsequent_turn")) + require.True(t, isSecurityAuditWebSocketStage("first_turn")) + require.True(t, isSecurityAuditWebSocketStage("subsequent_turn")) + require.False(t, isSecurityAuditWebSocketStage("http")) } func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) { @@ -48,9 +51,91 @@ func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) { require.Equal(t, int64(2), engine.enqueues.Load(), "subsequent WebSocket turns must be audited again") } +func TestRunSecurityAuditDeduplicatesRepeatedPayloadWithinWebSocketTurn(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := &turnCountingEngine{mode: securityaudit.ModeBlocking} + coordinator := securityaudit.NewCoordinator(nil, engine) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`) + c.Set(securityAuditWSTurnContextKey, 2) + first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + require.NotNil(t, first) + require.NotNil(t, second) + require.True(t, first.AllowNextStage) + require.True(t, second.AllowNextStage) + require.Equal(t, int64(1), engine.evaluates.Load()) + + // The cache holds only one successful same-turn result. + entry, exists := c.Get(securityAuditWSDedupeContextKey) + require.True(t, exists) + require.IsType(t, securityAuditWSDedupeEntry{}, entry) + + c.Set(securityAuditWSTurnContextKey, 3) + runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + require.Equal(t, int64(2), engine.evaluates.Load()) +} + +func TestRunSecurityAuditDoesNotCacheFailedWebSocketDecision(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := &turnCountingEngine{ + mode: securityaudit.ModeBlocking, + decisions: []*securityaudit.PromptDecision{ + {Kind: securityaudit.DecisionUnavailable, AllowNextStage: false}, + {Kind: securityaudit.DecisionAllow, AllowNextStage: true}, + }, + } + coordinator := securityaudit.NewCoordinator(nil, engine) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(securityAuditWSTurnContextKey, 2) + payload := []byte(`{"type":"response.create","response":{"input":"retry me"}}`) + + first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + _, cachedAfterFailure := c.Get(securityAuditWSDedupeContextKey) + second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + + require.False(t, first.AllowNextStage) + require.False(t, cachedAfterFailure) + require.True(t, second.AllowNextStage) + require.Equal(t, int64(2), engine.evaluates.Load()) +} + +func TestRunSecurityAuditDoesNotCacheFlaggedWebSocketDecision(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := &turnCountingEngine{ + mode: securityaudit.ModeBlocking, + decisions: []*securityaudit.PromptDecision{ + {Kind: securityaudit.DecisionFlag, AllowNextStage: true}, + {Kind: securityaudit.DecisionAllow, AllowNextStage: true}, + }, + } + coordinator := securityaudit.NewCoordinator(nil, engine) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Set(securityAuditWSTurnContextKey, 2) + payload := []byte(`{"type":"response.create","response":{"input":"retry flagged"}}`) + + first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + _, cachedAfterFlag := c.Get(securityAuditWSDedupeContextKey) + second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn") + + require.Equal(t, securityaudit.DecisionFlag, first.Kind) + require.True(t, first.AllowNextStage) + require.False(t, cachedAfterFlag) + require.Equal(t, securityaudit.DecisionAllow, second.Kind) + require.Equal(t, int64(2), engine.evaluates.Load()) +} + type turnCountingEngine struct { - mode securityaudit.Mode - enqueues atomic.Int64 + mode securityaudit.Mode + enqueues atomic.Int64 + evaluates atomic.Int64 + decisions []*securityaudit.PromptDecision } func (e *turnCountingEngine) EffectiveMode() securityaudit.Mode { return e.mode } @@ -59,5 +144,9 @@ func (e *turnCountingEngine) Enqueue(context.Context, securityaudit.Request) err return nil } func (e *turnCountingEngine) Evaluate(context.Context, securityaudit.Request) (*securityaudit.PromptDecision, error) { + call := e.evaluates.Add(1) + if int(call) <= len(e.decisions) { + return e.decisions[call-1], nil + } return &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}, nil } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 49f4d5f990..a415d67e34 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -44,58 +44,69 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { } response.Success(c, dto.PublicSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - PasskeyEnabled: settings.PasskeyEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: h.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaRegion: settings.TencentCaptchaRegion, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + CompactHomeEnabled: settings.CompactHomeEnabled, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: h.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index 45d66f8e33..0353a88628 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -82,6 +82,41 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, resp.Data.ForceEmailOnThirdPartySignup) } +func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &settingHandlerPublicRepoStub{ + values: map[string]string{ + service.SettingKeyTencentCaptchaEnabled: "true", + service.SettingKeyTencentCaptchaAppID: "123456789", + service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL, + }, + } + h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) + + h.GetPublicSettings(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.True(t, resp.Data.TencentCaptchaEnabled) + require.Equal(t, "123456789", resp.Data.TencentCaptchaAppID) + require.Equal(t, service.TencentCaptchaRegionINTL, resp.Data.TencentCaptchaRegion) +} + func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{ diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index 8dc9a8b442..1e480d2e34 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -224,6 +224,8 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) require.NotContains(t, body, "account_rate_multiplier") require.NotContains(t, body, "account_stats_cost") require.NotContains(t, body, "upstream_model") + require.NotContains(t, body, "upstream_response_model") + require.NotContains(t, body, "upstream_model_mismatch") require.NotContains(t, body, "billing_tier") require.NotContains(t, body, "channel_id") require.NotContains(t, body, `"account":`) diff --git a/backend/internal/handler/usage_record_submit_task_test.go b/backend/internal/handler/usage_record_submit_task_test.go index ebe5c3df82..66b3aca0d3 100644 --- a/backend/internal/handler/usage_record_submit_task_test.go +++ b/backend/internal/handler/usage_record_submit_task_test.go @@ -189,3 +189,33 @@ func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_ImageResultUsesMandator require.True(t, called.Load(), "image usage task must be mandatory when async submit is dropped") } + +func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_SearchCountUsesMandatoryFallback(t *testing.T) { + pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{ + WorkerCount: 1, + QueueSize: 1, + TaskTimeout: time.Second, + OverflowPolicy: "drop", + OverflowSamplePercent: 0, + AutoScaleEnabled: false, + }) + t.Cleanup(pool.Stop) + h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool} + + block := make(chan struct{}) + release := make(chan struct{}) + pool.Submit(func(ctx context.Context) { + close(block) + <-release + }) + <-block + pool.Submit(func(ctx context.Context) {}) + + var called atomic.Bool + h.submitOpenAIUsageRecordTask(context.Background(), &service.OpenAIForwardResult{SearchCount: 3}, func(ctx context.Context) { + called.Store(true) + }) + close(release) + + require.True(t, called.Load(), "search surcharge usage task must be mandatory when async submit is dropped") +} diff --git a/backend/internal/handler/usage_record_task_fallback_test.go b/backend/internal/handler/usage_record_task_fallback_test.go new file mode 100644 index 0000000000..b53a03fd74 --- /dev/null +++ b/backend/internal/handler/usage_record_task_fallback_test.go @@ -0,0 +1,77 @@ +package handler + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 本文件覆盖:worker 池已停止(进程关停窗口)时,计费任务不得静默丢失, +// 必须降级为内联同步执行;显式配置的 drop/sample 溢出丢弃仍按配置语义保留。 + +func newStoppedUsageRecordPoolForTest() *service.UsageRecordWorkerPool { + pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{ + WorkerCount: 1, + QueueSize: 1, + TaskTimeout: time.Second, + OverflowPolicy: config.UsageRecordOverflowPolicySync, + }) + pool.Stop() + return pool +} + +func TestGatewayHandlerSubmitUsageRecordTask_StoppedPoolFallsBackToSync(t *testing.T) { + h := &GatewayHandler{usageRecordWorkerPool: newStoppedUsageRecordPoolForTest()} + + executed := false + h.submitUsageRecordTask(context.Background(), func(ctx context.Context) { + executed = true + }) + require.True(t, executed, "池已停止时计费任务必须内联同步执行") +} + +func TestOpenAIGatewayHandlerSubmitUsageRecordTask_StoppedPoolFallsBackToSync(t *testing.T) { + h := &OpenAIGatewayHandler{usageRecordWorkerPool: newStoppedUsageRecordPoolForTest()} + + executed := false + h.submitUsageRecordTask(context.Background(), func(ctx context.Context) { + executed = true + }) + require.True(t, executed, "池已停止时计费任务必须内联同步执行") +} + +func TestGatewayHandlerSubmitUsageRecordTask_DropPolicyOverflowStillDrops(t *testing.T) { + pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{ + WorkerCount: 1, + QueueSize: 1, + TaskTimeout: time.Minute, + OverflowPolicy: config.UsageRecordOverflowPolicyDrop, + }) + t.Cleanup(pool.Stop) + h := &GatewayHandler{usageRecordWorkerPool: pool} + + started := make(chan struct{}) + block := make(chan struct{}) + t.Cleanup(func() { close(block) }) + // 占满 worker 槽位后再填满队列,保证第三个任务触发溢出。 + require.Equal(t, service.UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) { + close(started) + <-block + })) + <-started + require.Equal(t, service.UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) { + <-block + })) + + var executed atomic.Bool + h.submitUsageRecordTask(context.Background(), func(ctx context.Context) { + executed.Store(true) + }) + time.Sleep(50 * time.Millisecond) + require.False(t, executed.Load(), "drop 溢出策略是运维显式配置的取舍,不应被同步兜底覆盖") +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index fe559e6348..36fdb85280 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -157,9 +157,10 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui } // ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs. -func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { +func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler { h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) h.SetNotificationEmailService(notificationEmailService) + h.SetAliyunCaptchaService(aliyunCaptchaService) h.SetStepUpDeps(totpService, userService) return h } @@ -174,6 +175,7 @@ func ProvideHandlers( subscriptionHandler *SubscriptionHandler, announcementHandler *AnnouncementHandler, channelMonitorUserHandler *ChannelMonitorUserHandler, + channelMonitorV2Handler *ChannelMonitorV2Handler, adminHandlers *AdminHandlers, gatewayHandler *GatewayHandler, openaiGatewayHandler *OpenAIGatewayHandler, @@ -198,6 +200,7 @@ func ProvideHandlers( Subscription: subscriptionHandler, Announcement: announcementHandler, ChannelMonitor: channelMonitorUserHandler, + ChannelMonitorV2: channelMonitorV2Handler, Admin: adminHandlers, Gateway: gatewayHandler, OpenAIGateway: openaiGatewayHandler, @@ -224,6 +227,7 @@ var ProviderSet = wire.NewSet( NewSubscriptionHandler, NewAnnouncementHandler, NewChannelMonitorUserHandler, + NewChannelMonitorV2Handler, ProvideGatewayHandler, ProvideOpenAIGatewayHandler, NewTotpHandler, diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index f1c17427ad..c04f864793 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/payment" ) @@ -477,7 +478,11 @@ func summarizeEasyPayResponse(body []byte) string { return "" } if len(summary) > maxEasypayErrorSummary { - return summary[:maxEasypayErrorSummary] + "..." + truncated := summary[:maxEasypayErrorSummary] + for len(truncated) > 0 && !utf8.ValidString(truncated) { + truncated = truncated[:len(truncated)-1] + } + return truncated + "..." } return summary } diff --git a/backend/internal/payment/provider/easypay_refund_test.go b/backend/internal/payment/provider/easypay_refund_test.go index 3b76329870..8d535b5126 100644 --- a/backend/internal/payment/provider/easypay_refund_test.go +++ b/backend/internal/payment/provider/easypay_refund_test.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" "testing" + "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/payment" ) @@ -179,6 +180,18 @@ func TestEasyPayRefundResponseErrors(t *testing.T) { } } +func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) { + t.Parallel() + + summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171))) + if !utf8.ValidString(summary) { + t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary) + } + if !strings.HasSuffix(summary, "...") { + t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary) + } +} + func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) { t.Parallel() diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 59e31f8122..f09cc21b2d 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -230,6 +230,7 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen Amount: stripe.Int64(amountInMinorUnit), Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)), } + params.SetIdempotencyKey(fmt.Sprintf("re-%s-%d", req.OrderID, amountInMinorUnit)) params.Context = ctx r, err := s.sc.V1Refunds.Create(ctx, params) diff --git a/backend/internal/payment/provider/stripe_test.go b/backend/internal/payment/provider/stripe_test.go new file mode 100644 index 0000000000..9c327898ff --- /dev/null +++ b/backend/internal/payment/provider/stripe_test.go @@ -0,0 +1,70 @@ +//go:build unit + +package provider + +import ( + "bytes" + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" + stripe "github.com/stripe/stripe-go/v85" +) + +type stripeRefundBackend struct { + params []*stripe.RefundCreateParams +} + +func (b *stripeRefundBackend) Call(_ string, _ string, _ string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error { + b.params = append(b.params, params.(*stripe.RefundCreateParams)) + refund := v.(*stripe.Refund) + refund.ID = "re_123" + refund.Status = stripe.RefundStatusSucceeded + return nil +} + +func (*stripeRefundBackend) CallStreaming(string, string, string, stripe.ParamsContainer, stripe.StreamingLastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) CallRaw(string, string, string, []byte, *stripe.Params, stripe.LastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) CallMultipart(string, string, string, string, *bytes.Buffer, *stripe.Params, stripe.LastResponseSetter) error { + return nil +} + +func (*stripeRefundBackend) SetMaxNetworkRetries(int64) {} + +func TestStripeRefundUsesStableAmountSpecificIdempotencyKey(t *testing.T) { + backend := &stripeRefundBackend{} + client := stripe.NewClient("sk_test", stripe.WithBackends(&stripe.Backends{API: backend})) + provider := &Stripe{ + config: map[string]string{"currency": "CNY"}, + initialized: true, + sc: client, + } + + refund := func(amount string) { + _, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "pi_123", + OrderID: "sub2_order_456", + Amount: amount, + }) + require.NoError(t, err) + } + + refund("12.34") + refund("12.34") + refund("12.35") + + require.Len(t, backend.params, 3) + require.Equal(t, int64(1234), *backend.params[0].Amount) + require.Equal(t, "re-sub2_order_456-1234", *backend.params[0].IdempotencyKey) + require.Equal(t, backend.params[0].IdempotencyKey, backend.params[1].IdempotencyKey) + require.Equal(t, int64(1235), *backend.params[2].Amount) + require.Equal(t, "re-sub2_order_456-1235", *backend.params[2].IdempotencyKey) + require.NotEqual(t, *backend.params[0].IdempotencyKey, *backend.params[2].IdempotencyKey) +} diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index d8732d26ed..c0335c4156 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -178,6 +178,11 @@ var geminiModels = []modelDef{ {ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true}, {ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"}, {ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"}, + {ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z"}, + {ID: "gemini-3.6-flash-high", DisplayName: "Gemini 3.6 Flash High", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, + {ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true}, {ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"}, } diff --git a/backend/internal/pkg/antigravity/claude_types_test.go b/backend/internal/pkg/antigravity/claude_types_test.go index bb45c2f0c3..65c8078c33 100644 --- a/backend/internal/pkg/antigravity/claude_types_test.go +++ b/backend/internal/pkg/antigravity/claude_types_test.go @@ -20,6 +20,11 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { "gemini-3.1-flash-image", "gemini-3.1-flash-image-preview", "gemini-3-pro-image", // legacy compatibility + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", } for _, id := range requiredIDs { diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 39b6d2c90c..785c8af3c4 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -585,7 +585,7 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s return "", lastErr } - // done=false 时等待后重试(与 CLIProxyAPI 行为一致) + // done=false 时等待后重试,避免在上游仍未完成时提前结束轮询。 select { case <-time.After(2 * time.Second): case <-ctx.Done(): diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index e4fa5541c1..1a62f5620f 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -423,19 +423,20 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str // + the reasoning→thinking mapping in ResponsesToAnthropic. func chatMessageToAnthropicBlocks(message ChatMessage) []AnthropicContentBlock { var blocks []AnthropicContentBlock + reasoning := message.reasoningText() - if message.ReasoningContent != "" { + if reasoning != "" { blocks = append(blocks, AnthropicContentBlock{ Type: "thinking", - Thinking: message.ReasoningContent, + Thinking: reasoning, }) } text := chatMessageContentText(message.Content) // DeepSeek reasoning-only fallback: when there is no text and no tool calls, // surface the reasoning content as visible text so the turn isn't empty. - if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 { - text = message.ReasoningContent + if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 { + text = reasoning } if text != "" || len(message.ToolCalls) == 0 { blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: text}) @@ -608,11 +609,12 @@ func ChatCompletionsChunkToAnthropicEvents( for _, choice := range chunk.Choices { // Reasoning content → thinking block. - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + reasoning := choice.Delta.reasoningText() + if reasoning != nil && *reasoning != "" { events = append(events, ensureCCAnthropicThinkingBlock(state)...) events = append(events, ccAnthropicDelta(state, &AnthropicDelta{ Type: "thinking_delta", - Thinking: *choice.Delta.ReasoningContent, + Thinking: *reasoning, })...) } diff --git a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go new file mode 100644 index 0000000000..87c4f9e0ba --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go @@ -0,0 +1,76 @@ +package apicompat + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func readIssue5302Fixture(t *testing.T, name string, dst any) { + t.Helper() + payload, err := os.ReadFile(filepath.Join("testdata", "issue5302", name)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(payload, dst)) +} + +func TestChatReasoningAlias_AnthropicNonStreaming(t *testing.T) { + var response ChatCompletionsResponse + readIssue5302Fixture(t, "nonstream_reasoning.json", &response) + + out := ChatCompletionsResponseToAnthropic(&response, "claude-sonnet-4-20250514") + require.Len(t, out.Content, 2) + require.Equal(t, "thinking", out.Content[0].Type) + require.Equal(t, "fallback reasoning", out.Content[0].Thinking) + require.Equal(t, "final answer", out.Content[1].Text) +} + +func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) { + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "stream_reasoning.json", &chunk) + + events := ChatCompletionsChunkToAnthropicEvents(&chunk, NewChatCompletionsToAnthropicStreamState("reasoning-model")) + var thinking string + for _, event := range events { + if event.Delta != nil && event.Delta.Type == "thinking_delta" { + thinking += event.Delta.Thinking + } + } + require.Equal(t, "streamed fallback", thinking) +} + +func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) { + var response ChatCompletionsResponse + readIssue5302Fixture(t, "nonstream_reasoning.json", &response) + nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", 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) + + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "stream_reasoning.json", &chunk) + events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model")) + var deltas []string + for _, event := range events { + if event.Type == "response.reasoning_summary_text.delta" { + deltas = append(deltas, event.Delta) + } + } + require.Equal(t, []string{"streamed fallback"}, deltas) +} + +func TestChatReasoningAlias_ReasoningContentTakesPrecedence(t *testing.T) { + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "reasoning_content_precedence.json", &chunk) + + events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model")) + var deltas []string + for _, event := range events { + if event.Type == "response.reasoning_summary_text.delta" { + deltas = append(deltas, event.Delta) + } + } + require.Equal(t, []string{"preferred reasoning"}, deltas) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 0d2de969dd..4e20fe4216 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -1,6 +1,7 @@ package apicompat import ( + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -9,6 +10,13 @@ import ( "time" ) +const ( + toolOutputMediaMarker = "[Tool output media moved to the following user message]" + toolOutputMediaAttribution = "[Tool output media for call %s]" +) + +type toolOutputMediaByCallID map[string][]ChatContentPart + // ResponsesToChatCompletionsRequest converts a Responses API request into a // Chat Completions request for upstreams that only implement // /v1/chat/completions. @@ -215,16 +223,16 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) return nil, fmt.Errorf("parse responses input: %w", err) } - built, err := buildChatMessagesFromItems(messages, rawItems) + built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems) if err != nil { return nil, err } - return normalizeChatMessages(built), nil + return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil } // buildChatMessagesFromItems walks the Responses input items and appends the // corresponding Chat messages. -func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, error) { +func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]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 @@ -232,6 +240,7 @@ 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 + mediaByCallID := make(toolOutputMediaByCallID) for _, raw := range rawItems { raw = bytesTrimSpace(raw) @@ -248,7 +257,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa pendingReasoning = "" continue } - return nil, fmt.Errorf("parse responses input item: %w", err) + return nil, nil, fmt.Errorf("parse responses input item: %w", err) } role := chatCompletionsBridgeRole(rawString(item["role"])) @@ -320,15 +329,25 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa continue case "function_call_output", "custom_tool_call_output", "tool_search_output": outputRaw := bytesTrimSpace(item["output"]) - outputText := rawString(outputRaw) - if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` { - // 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。 - outputText = string(outputRaw) + callID := rawString(item["call_id"]) + delete(mediaByCallID, callID) + + outputText, media, rewritten := extractToolOutputMedia(outputRaw) + if rewritten { + if callID != "" { + mediaByCallID[callID] = media + } + } else { + outputText = rawString(outputRaw) + if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` { + // 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。 + outputText = string(outputRaw) + } } content, _ := json.Marshal(outputText) messages = append(messages, ChatMessage{ Role: "tool", - ToolCallID: rawString(item["call_id"]), + ToolCallID: callID, Content: content, }) pendingReasoning = "" @@ -341,7 +360,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa case "input_image": content, err := chatContentFromSingleResponsesPart(itemType, item) if err != nil { - return nil, err + return nil, nil, err } messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" @@ -367,7 +386,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } chatContent, err := responsesContentToChatContent(content, role) if err != nil { - return nil, err + return nil, nil, err } messages = append(messages, ChatMessage{Role: role, Content: chatContent}) // Reasoning only survives across an assistant text message. @@ -376,7 +395,141 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } } - return messages, nil + return messages, mediaByCallID, nil +} + +// extractToolOutputMedia rewrites only recognized image nodes. Media-free +// outputs return rewritten=false so the caller can preserve their original +// bytes and prompt-cache prefix. +func extractToolOutputMedia(outputRaw json.RawMessage) (string, []ChatContentPart, bool) { + outputRaw = bytesTrimSpace(outputRaw) + if len(outputRaw) == 0 || string(outputRaw) == "null" { + return "", nil, false + } + + var outputString string + if err := json.Unmarshal(outputRaw, &outputString); err == nil { + if isToolOutputImageDataURL(outputString) { + return toolOutputMediaMarker, []ChatContentPart{toolOutputImagePart(outputString)}, true + } + + nested, ok := decodeToolOutputJSON([]byte(outputString)) + if !ok { + return "", nil, false + } + rewritten, media, changed := rewriteToolOutputMediaValue(nested) + if !changed { + return "", nil, false + } + encoded, err := json.Marshal(rewritten) + if err != nil { + return "", nil, false + } + return string(encoded), media, true + } + + value, ok := decodeToolOutputJSON(outputRaw) + if !ok { + return "", nil, false + } + rewritten, media, changed := rewriteToolOutputMediaValue(value) + if !changed { + return "", nil, false + } + encoded, err := json.Marshal(rewritten) + if err != nil { + return "", nil, false + } + return string(encoded), media, true +} + +func decodeToolOutputJSON(raw []byte) (any, bool) { + if !json.Valid(raw) { + return nil, false + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false + } + return value, true +} + +func rewriteToolOutputMediaValue(value any) (any, []ChatContentPart, bool) { + switch typed := value.(type) { + case []any: + var media []ChatContentPart + changed := false + for i, item := range typed { + rewritten, itemMedia, itemChanged := rewriteToolOutputMediaValue(item) + if !itemChanged { + continue + } + typed[i] = rewritten + media = append(media, itemMedia...) + changed = true + } + return typed, media, changed + case map[string]any: + if imageURL, ok := recognizedToolOutputImageURL(typed); ok { + return map[string]any{ + "type": "input_text", + "text": toolOutputMediaMarker, + }, []ChatContentPart{toolOutputImagePart(imageURL)}, true + } + + content, ok := typed["content"] + if !ok { + return typed, nil, false + } + rewritten, media, changed := rewriteToolOutputMediaValue(content) + if !changed { + return typed, nil, false + } + typed["content"] = rewritten + return typed, media, true + default: + return value, nil, false + } +} + +func recognizedToolOutputImageURL(value map[string]any) (string, bool) { + partType, _ := value["type"].(string) + if partType != "input_image" && partType != "image_url" { + return "", false + } + + switch imageURL := value["image_url"].(type) { + case string: + return imageURL, strings.TrimSpace(imageURL) != "" + case map[string]any: + url, _ := imageURL["url"].(string) + return url, strings.TrimSpace(url) != "" + default: + return "", false + } +} + +func isToolOutputImageDataURL(value string) bool { + const prefix = "data:image/" + const separator = ";base64," + if !strings.HasPrefix(value, prefix) { + return false + } + separatorIndex := strings.Index(value[len(prefix):], separator) + if separatorIndex <= 0 { + return false + } + payloadIndex := len(prefix) + separatorIndex + len(separator) + return payloadIndex < len(value) +} + +func toolOutputImagePart(imageURL string) ChatContentPart { + return ChatContentPart{ + Type: "image_url", + ImageURL: &ChatImageURL{URL: imageURL}, + } } // appendAssistantToolCall merges a tool call into the chat message list. @@ -417,6 +570,10 @@ func appendAssistantToolCall(messages []ChatMessage, toolCall ChatToolCall, pend // tool replies and intervening messages are emitted in their natural position // but never between an assistant tool_calls message and its replies. func normalizeChatMessages(messages []ChatMessage) []ChatMessage { + return normalizeChatMessagesWithToolOutputMedia(messages, nil) +} + +func normalizeChatMessagesWithToolOutputMedia(messages []ChatMessage, mediaByCallID toolOutputMediaByCallID) []ChatMessage { // Index every tool reply by its tool_call_id (last wins on duplicates). replies := make(map[string]ChatMessage) for _, m := range messages { @@ -463,6 +620,23 @@ func normalizeChatMessages(messages []ChatMessage) []ChatMessage { for _, tc := range kept { out = append(out, replies[tc.ID]) } + + var mediaParts []ChatContentPart + for _, tc := range kept { + media := mediaByCallID[tc.ID] + if len(media) == 0 { + continue + } + mediaParts = append(mediaParts, ChatContentPart{ + Type: "text", + Text: fmt.Sprintf(toolOutputMediaAttribution, tc.ID), + }) + mediaParts = append(mediaParts, media...) + } + if len(mediaParts) > 0 { + content, _ := json.Marshal(mediaParts) + out = append(out, ChatMessage{Role: "user", Content: content}) + } default: out = append(out, m) } @@ -879,20 +1053,21 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput - if message.ReasoningContent != "" { + reasoning := message.reasoningText() + if reasoning != "" { outputs = append(outputs, ResponsesOutput{ Type: "reasoning", ID: generateItemID(), Summary: []ResponsesSummary{{ Type: "summary_text", - Text: message.ReasoningContent, + Text: reasoning, }}, }) } text := chatMessageContentText(message.Content) - if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 { - text = message.ReasoningContent + if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 { + text = reasoning } if text != "" || len(message.ToolCalls) == 0 { outputs = append(outputs, ResponsesOutput{ @@ -1154,13 +1329,14 @@ func ChatCompletionsChunkToResponsesEvents( // (output_item.added + reasoning_summary_part.added) before the first // delta, otherwise a strict client discards the delta. The leading // empty-string reasoning delta upstreams send is filtered out. - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + reasoning := choice.Delta.reasoningText() + if reasoning != nil && *reasoning != "" { events = append(events, ensureChatReasoningItem(state)...) - _, _ = state.Reasoning.WriteString(*choice.Delta.ReasoningContent) + _, _ = state.Reasoning.WriteString(*reasoning) events = append(events, chatToResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{ OutputIndex: state.ReasoningIndex, SummaryIndex: 0, - Delta: *choice.Delta.ReasoningContent, + Delta: *reasoning, ItemID: state.ReasoningItemID, })) } diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go new file mode 100644 index 0000000000..46c9c8b4cd --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go @@ -0,0 +1,318 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + testToolImageDataURL = "data:image/png;base64,AQID" + testToolImageRemote = "https://example.com/tool-output.png" +) + +func TestResponsesToolOutputMedia_ExtractsSupportedShapes(t *testing.T) { + tests := []struct { + name string + call string + outputType string + output string + imageURL string + toolText string + }{ + { + name: "image-only array", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`, + imageURL: testToolImageDataURL, + }, + { + name: "text and nested image URL", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `[{"type":"input_text","text":"render complete"},{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`, + imageURL: testToolImageRemote, + toolText: "render complete", + }, + { + name: "top-level image object", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `{"type":"input_image","image_url":"data:image/png;base64,AQID"}`, + imageURL: testToolImageDataURL, + }, + { + name: "content wrapper", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `{"status":"ok","content":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}],"unknown":{"score":0.9,"large":9007199254740993}}`, + imageURL: testToolImageDataURL, + toolText: `"large":9007199254740993`, + }, + { + name: "JSON string output", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `"[{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AQID\"}]"`, + imageURL: testToolImageDataURL, + }, + { + name: "bare data URL", + call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`, + outputType: "function_call_output", + output: `"data:image/png;base64,AQID"`, + imageURL: testToolImageDataURL, + }, + { + name: "custom tool output", + call: `{"type":"custom_tool_call","call_id":"call_image","name":"view_image","input":"{}"}`, + outputType: "custom_tool_call_output", + output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`, + imageURL: testToolImageDataURL, + }, + { + name: "tool search output", + call: `{"type":"tool_search_call","call_id":"call_image","arguments":{"query":"image"}}`, + outputType: "tool_search_output", + output: `[{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`, + imageURL: testToolImageRemote, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := fmt.Sprintf(`[%s,{"type":%q,"call_id":"call_image","output":%s}]`, tt.call, tt.outputType, tt.output) + messages := convertToolOutputMedia(t, input) + + require.Len(t, messages, 3) + require.Equal(t, []string{"assistant", "tool", "user"}, chatMessageRoles(messages)) + require.Equal(t, "call_image", messages[1].ToolCallID) + + toolText := chatToolContentString(t, messages[1]) + require.Contains(t, toolText, "[Tool output media moved to the following user message]") + require.NotContains(t, toolText, tt.imageURL) + if tt.toolText != "" { + require.Contains(t, toolText, tt.toolText) + } + + parts := chatContentParts(t, messages[2]) + require.Len(t, parts, 2) + require.Equal(t, "text", parts[0].Type) + require.Equal(t, "[Tool output media for call call_image]", parts[0].Text) + require.Equal(t, "image_url", parts[1].Type) + require.NotNil(t, parts[1].ImageURL) + require.Equal(t, tt.imageURL, parts[1].ImageURL.URL) + }) + } +} + +func TestResponsesToolOutputMedia_ParallelBatchUsesCallOrder(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":{"url":"https://example.com/b.png"}}]}, + {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":{"url":"https://example.com/a.png"}}]} + ]`) + + require.Len(t, messages, 4) + require.Equal(t, []string{"assistant", "tool", "tool", "user"}, chatMessageRoles(messages)) + require.Equal(t, []string{"call_A", "call_B"}, []string{messages[1].ToolCallID, messages[2].ToolCallID}) + + parts := chatContentParts(t, messages[3]) + require.Len(t, parts, 4) + require.Equal(t, "[Tool output media for call call_A]", parts[0].Text) + require.Equal(t, "https://example.com/a.png", parts[1].ImageURL.URL) + require.Equal(t, "[Tool output media for call call_B]", parts[2].Text) + require.Equal(t, "https://example.com/b.png", parts[3].ImageURL.URL) +} + +func TestResponsesToolOutputMedia_PreservesRichSiblingWhenRewriting(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_image","output":[ + {"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}, + {"type":"input_image","image_url":"data:image/png;base64,AQID"} + ]} + ]`) + + toolText := chatToolContentString(t, messages[1]) + require.JSONEq(t, `[ + {"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}, + {"type":"input_text","text":"[Tool output media moved to the following user message]"} + ]`, toolText) +} + +func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"approval saved"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]} + ]`) + + require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages)) + require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text) + require.JSONEq(t, `"approval saved"`, string(messages[3].Content)) + require.JSONEq(t, `"continue"`, string(messages[4].Content)) +} + +func TestResponsesToolOutputMedia_DropsOrphanAndUnansweredCallMedia(t *testing.T) { + t.Run("orphan", func(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call_output","call_id":"call_ghost","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]} + ]`) + require.Empty(t, messages) + }) + + t.Run("unanswered parallel call", func(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]} + ]`) + + require.Len(t, messages, 3) + require.Len(t, messages[0].ToolCalls, 1) + require.Equal(t, "call_A", messages[0].ToolCalls[0].ID) + parts := chatContentParts(t, messages[2]) + require.Len(t, parts, 2) + require.NotContains(t, string(messages[2].Content), "call_B") + }) +} + +func TestResponsesToolOutputMedia_PreservesMediaFreeOutputBytes(t *testing.T) { + tests := []struct { + name string + output string + }{ + { + name: "rich unknown object", + output: `{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}`, + }, + { + name: "no-image array", + output: `[ { "type": "input_text", "text": "ok" }, {"unknown":true} ]`, + }, + { + name: "plain string", + output: `"plain output"`, + }, + { + name: "JSON string without image", + output: `"{ \"ok\": true }"`, + }, + { + name: "embedded data URL text", + output: `"prefix data:image/png;base64,AQID suffix"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := fmt.Sprintf(`[ + {"type":"function_call","call_id":"call_text","name":"exec","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_text","output":%s} + ]`, tt.output) + messages := convertToolOutputMedia(t, input) + require.Len(t, messages, 2) + + var expected string + if err := json.Unmarshal([]byte(tt.output), &expected); err != nil { + expected = tt.output + } + expectedContent, err := json.Marshal(expected) + require.NoError(t, err) + require.Equal(t, string(expectedContent), string(messages[1].Content)) + }) + } +} + +func TestResponsesToolOutputMedia_DuplicateCallIDIsLastWins(t *testing.T) { + t.Run("later media replaces earlier media", func(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/first.png"}}]}, + {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/last.png"}}]} + ]`) + + require.Len(t, messages, 3) + require.NotContains(t, string(messages[1].Content), "first.png") + require.NotContains(t, string(messages[2].Content), "first.png") + require.Contains(t, string(messages[2].Content), "last.png") + }) + + t.Run("later text clears earlier media", func(t *testing.T) { + messages := convertToolOutputMedia(t, `[ + {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}, + {"type":"function_call_output","call_id":"call_image","output":"latest text"} + ]`) + + require.Len(t, messages, 2) + require.Equal(t, "latest text", chatToolContentString(t, messages[1])) + }) +} + +func TestResponsesToChatCompletionsRequest_ToolContentNeverContainsExtractedMedia(t *testing.T) { + req := &ResponsesRequest{ + Model: "vision-model", + Input: json.RawMessage(`[ + {"type":"function_call","call_id":"call_function","name":"view_image","arguments":"{}"}, + {"type":"custom_tool_call","call_id":"call_custom","name":"custom_image","input":"{}"}, + {"type":"tool_search_call","call_id":"call_search","arguments":{"query":"image"}}, + {"type":"function_call_output","call_id":"call_function","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}, + {"type":"custom_tool_call_output","call_id":"call_custom","output":{"content":[{"type":"image_url","image_url":{"url":"https://example.com/custom.png"}}]}}, + {"type":"tool_search_output","call_id":"call_search","output":"data:image/jpeg;base64,BAUG"} + ]`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + assertChatInvariants(t, out.Messages) + + var toolCount int + for _, message := range out.Messages { + if message.Role != "tool" { + continue + } + toolCount++ + content := string(message.Content) + require.NotContains(t, content, "data:image/") + require.NotContains(t, content, "https://example.com/custom.png") + } + require.Equal(t, 3, toolCount) + require.Equal(t, []string{"assistant", "tool", "tool", "tool", "user"}, chatMessageRoles(out.Messages)) +} + +func convertToolOutputMedia(t *testing.T, input string) []ChatMessage { + t.Helper() + messages, err := responsesInputToChatMessages("", json.RawMessage(input)) + require.NoError(t, err) + assertChatInvariants(t, messages) + return messages +} + +func chatToolContentString(t *testing.T, message ChatMessage) string { + t.Helper() + require.Equal(t, "tool", message.Role) + var content string + require.NoError(t, json.Unmarshal(message.Content, &content)) + return content +} + +func chatContentParts(t *testing.T, message ChatMessage) []ChatContentPart { + t.Helper() + var parts []ChatContentPart + require.NoError(t, json.Unmarshal(message.Content, &parts)) + for _, part := range parts { + if part.Type == "image_url" { + require.NotNil(t, part.ImageURL) + require.False(t, strings.TrimSpace(part.ImageURL.URL) == "") + } + } + return parts +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go new file mode 100644 index 0000000000..ed410f6851 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go @@ -0,0 +1,207 @@ +package apicompat + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block +// 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回 +// 400 "Request body format invalid"(见 issue #5329)。 +var anthropicInboundBlockTypes = map[string]bool{ + "text": true, + "image": true, + "document": true, + "tool_use": true, + "tool_result": true, + "thinking": true, + "redacted_thinking": true, +} + +func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage { + t.Helper() + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req)) + out, err := ResponsesToAnthropicRequest(&req) + require.NoError(t, err) + return out.Messages +} + +// requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态: +// 未知 block 类型、空内容消息、纯空白 text 块。 +func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) { + t.Helper() + for i, m := range messages { + raw := strings.TrimSpace(string(m.Content)) + require.NotContains(t, []string{"", "null", `""`, "[]"}, raw, + "messages[%d] 内容为空,Anthropic 拒收空内容消息", i) + + var s string + if err := json.Unmarshal(m.Content, &s); err == nil { + require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i) + continue + } + blocks := parseContentBlocks(m.Content) + require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i) + for j, b := range blocks { + require.True(t, anthropicInboundBlockTypes[b.Type], + "messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type) + if b.Type == "text" { + require.NotEmpty(t, strings.TrimSpace(b.Text), + "messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j) + } + } + } +} + +// issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。 +// 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。 +func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]}, + {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.NotContains(t, string(messages[0].Content), "reasoning_text") + require.NotContains(t, string(messages[0].Content), "let me think") +} + +// Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃, +// 这条守卫确保行为没有被改变。 +func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.NotContains(t, string(messages[0].Content), "gAAAA") +} + +// 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。 +func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Empty(t, messages, "整条内容都无法映射时不应发出消息") +} + +// 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。 +func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"some_future_item","content":[ + {"type":"input_text","text":"keep me"}, + {"type":"reasoning_text","text":"drop me"} + ]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.Contains(t, string(messages[0].Content), "keep me") + require.NotContains(t, string(messages[0].Content), "drop me") +} + +// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。 +func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Empty(t, messages) +} + +// assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。 +func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}, + {"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.Equal(t, "user", messages[0].Role) +} + +// 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变, +// 同时整个序列满足可发送不变式。 +func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]}, + {"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"}, + {"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"file1"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + + var sawToolUse, sawToolResult bool + for _, m := range messages { + for _, b := range parseContentBlocks(m.Content) { + switch b.Type { + case "tool_use": + sawToolUse = true + require.Equal(t, "call_1", b.ID) + require.Equal(t, "shell", b.Name) + case "tool_result": + sawToolResult = true + require.Equal(t, "call_1", b.ToolUseID) + } + } + } + require.True(t, sawToolUse, "function_call 必须转成 tool_use") + require.True(t, sawToolResult, "function_call_output 必须转成 tool_result") + require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text") + require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA") +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func TestAnthropicContentIsEmpty(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {``, true}, + {`""`, true}, + {`null`, true}, + {`[]`, true}, + {` [] `, true}, + {`"hi"`, false}, + {`[{"type":"text","text":"hi"}]`, false}, + } + for _, tc := range cases { + require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw) + } +} + +func TestAnthropicContentIsOnlyBlankText(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {`[{"type":"text","text":""}]`, true}, + {`[{"type":"text","text":" "}]`, true}, + {`[{"type":"text","text":""},{"type":"text","text":" "}]`, true}, + {`[{"type":"text","text":"hi"}]`, false}, + {`[{"type":"text","text":""},{"type":"image","source":{}}]`, false}, + {`[]`, false}, + } + for _, tc := range cases { + require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw) + } +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 7a28b44abc..51ffe8baaf 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -164,11 +164,24 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess Content: blockJSON, }) + case item.Type == "reasoning": + // Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的, + // 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。 + // Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃, + // 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被 + // 原样塞进 Anthropic 请求体,上游直接回 400。 + case item.Role == "user": content, err := convertResponsesUserToAnthropicContent(item.Content) if err != nil { return nil, nil, err } + // 内容里只有网关不认识的分片时,sanitize 会得到空串。Anthropic 拒收 + // 空内容消息("all messages must have non-empty content"),整条丢掉 + // 比发一条必然 400 的消息更可用。 + if anthropicContentIsEmpty(content) { + continue + } messages = append(messages, AnthropicMessage{ Role: "user", Content: content, @@ -179,19 +192,35 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess if err != nil { return nil, nil, err } + // 同上:分片全不认识时会退化成单个空 text 块,而 Anthropic 拒收 + // 空文本块("text content blocks must contain non-whitespace text")。 + if anthropicContentIsEmpty(content) || anthropicContentIsOnlyBlankText(content) { + continue + } messages = append(messages, AnthropicMessage{ Role: "assistant", Content: content, }) default: - // Unknown role/type — attempt as user message - if item.Content != nil { - messages = append(messages, AnthropicMessage{ - Role: "user", - Content: item.Content, - }) + // 未知 role/type —— 尽量当作 user 消息保留其中的文本/图片。 + // 必须走与真实 user 消息同一套白名单转换:直接透传 item.Content 会把 + // Responses 专有的分片类型(reasoning_text、web_search_call 的载荷等) + // 原样发给 Anthropic,上游只会回 400 把整轮打挂。 + if item.Content == nil { + continue } + content, err := convertResponsesUserToAnthropicContent(item.Content) + if err != nil { + return nil, nil, err + } + if anthropicContentIsEmpty(content) { + continue + } + messages = append(messages, AnthropicMessage{ + Role: "user", + Content: content, + }) } } @@ -393,6 +422,32 @@ func extractTextFromContent(raw json.RawMessage) string { // convertResponsesUserToAnthropicContent converts a Responses user message // content field into Anthropic content blocks JSON. +// anthropicContentIsEmpty 判断转换结果是否为"空内容"。 +// convertResponsesUserToAnthropicContent 在没有任何可识别分片时返回 JSON 空串, +// 而 Anthropic 拒收空内容消息。 +func anthropicContentIsEmpty(content json.RawMessage) bool { + trimmed := strings.TrimSpace(string(content)) + switch trimmed { + case "", "null", `""`, "[]": + return true + } + return false +} + +// anthropicContentIsOnlyBlankText 判断内容是否只由空白 text 块组成。 +func anthropicContentIsOnlyBlankText(content json.RawMessage) bool { + blocks := parseContentBlocks(content) + if len(blocks) == 0 { + return false + } + for _, b := range blocks { + if b.Type != "text" || strings.TrimSpace(b.Text) != "" { + return false + } + } + return true +} + func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) { if len(raw) == 0 { return json.Marshal("") // empty string content diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json b/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json new file mode 100644 index 0000000000..e31e463b28 --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json @@ -0,0 +1,19 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "final answer", + "reasoning": "fallback reasoning", + "reasoning_details": [ + {"type": "reasoning.text", "text": "fallback reasoning"} + ] + }, + "finish_reason": "stop" + } + ] +} diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json b/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json new file mode 100644 index 0000000000..5040d46f2c --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json @@ -0,0 +1,15 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion.chunk", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "delta": { + "reasoning_content": "preferred reasoning", + "reasoning": "fallback reasoning" + }, + "finish_reason": null + } + ] +} diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json b/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json new file mode 100644 index 0000000000..66c4e8ae7b --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json @@ -0,0 +1,17 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion.chunk", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "delta": { + "reasoning": "streamed fallback", + "reasoning_details": [ + {"type": "reasoning.text", "text": "streamed fallback"} + ] + }, + "finish_reason": null + } + ] +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c4cde405e1..c484a7e589 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -651,6 +651,7 @@ type ChatMessage struct { Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function" Content json.RawMessage `json:"content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` Name string `json:"name,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` @@ -771,9 +772,24 @@ type ChatDelta struct { Role string `json:"role,omitempty"` Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters ReasoningContent *string `json:"reasoning_content,omitempty"` + Reasoning *string `json:"reasoning,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` } +func (m ChatMessage) reasoningText() string { + if m.ReasoningContent != "" { + return m.ReasoningContent + } + return m.Reasoning +} + +func (d ChatDelta) reasoningText() *string { + if d.ReasoningContent != nil { + return d.ReasoningContent + } + return d.Reasoning +} + // --------------------------------------------------------------------------- // Shared constants // --------------------------------------------------------------------------- diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index 2ec3e0fdbe..c34ef589a0 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -50,6 +50,12 @@ const ( // OpenAIImageGenerationIntent 标识 OpenAI 请求会触发生图能力(用于图片能力维度限流) OpenAIImageGenerationIntent Key = "ctx_openai_image_generation_intent" + // OpenAIImagesEndpoint 标识请求是从 /v1/images/* 入站的。 + // 与 OpenAIImageGenerationIntent 的区别:后者只表示"这次请求会生图", + // /v1/responses 带图片模型时也会置位;本 key 只在专用生图端点置位, + // 用于区分"用错端点"与"端点用对了但账号没能力"。 + OpenAIImagesEndpoint Key = "ctx_openai_images_endpoint" + // Group 认证后的分组信息,由 API Key 认证中间件设置 Group Key = "ctx_group" diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go index ff1347d809..367bcc440c 100644 --- a/backend/internal/pkg/ip/ip.go +++ b/backend/internal/pkg/ip/ip.go @@ -113,13 +113,13 @@ func resolveCustomForwardedClientIP(c *gin.Context, headers []string) (string, s func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) { var fallback string - if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" { + if forwarded := normalizeValidIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" { fallback = forwarded if !isPrivateIP(forwarded) { return forwarded, fallback } } - if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" { + if realIP := normalizeValidIP(c.GetHeader("X-Real-IP")); realIP != "" { if fallback == "" { fallback = realIP } @@ -130,13 +130,18 @@ func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) { if xff := c.GetHeader("X-Forwarded-For"); xff != "" { ips := strings.Split(xff, ",") for _, candidate := range ips { - candidate = strings.TrimSpace(candidate) + candidate = normalizeValidIP(candidate) if candidate != "" && !isPrivateIP(candidate) { - return normalizeIP(candidate), fallback + return candidate, fallback } } - if fallback == "" && len(ips) > 0 { - fallback = normalizeIP(strings.TrimSpace(ips[0])) + if fallback == "" { + for _, candidate := range ips { + if candidate = normalizeValidIP(candidate); candidate != "" { + fallback = candidate + break + } + } } } return "", fallback @@ -176,6 +181,16 @@ func normalizeIP(ip string) string { return ip } +// normalizeValidIP 规范化并验证代理头中的候选值,避免把 unknown、主机名等非法值传给安全服务。 +func normalizeValidIP(value string) string { + normalized := normalizeIP(value) + parsed := net.ParseIP(normalized) + if parsed == nil { + return "" + } + return parsed.String() +} + // privateNets contains the private/loopback ranges skipped while selecting a // public address from a legacy X-Forwarded-For chain. var privateNets []*net.IPNet diff --git a/backend/internal/pkg/ip/ip_test.go b/backend/internal/pkg/ip/ip_test.go index 505ade5eea..8aece8e6a4 100644 --- a/backend/internal/pkg/ip/ip_test.go +++ b/backend/internal/pkg/ip/ip_test.go @@ -153,6 +153,26 @@ func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) { }, want: "4.4.4.4", }, + { + name: "invalid legacy values continue to a valid forwarded address", + trustForward: true, + requestHeaders: map[string]string{ + "CF-Connecting-IP": "unknown", + "X-Real-IP": "proxy.internal", + "X-Forwarded-For": "also-invalid, 203.0.113.50", + }, + want: "203.0.113.50", + }, + { + name: "all invalid legacy values fall back to the connection address", + trustForward: true, + requestHeaders: map[string]string{ + "CF-Connecting-IP": "unknown", + "X-Real-IP": "proxy.internal", + "X-Forwarded-For": "also-invalid", + }, + want: "9.9.9.9", + }, { name: "disabled mode ignores custom and legacy headers", trustForward: false, diff --git a/backend/internal/pkg/oauth/oauth.go b/backend/internal/pkg/oauth/oauth.go index c5ef3c6e08..358d93f1bf 100644 --- a/backend/internal/pkg/oauth/oauth.go +++ b/backend/internal/pkg/oauth/oauth.go @@ -19,7 +19,7 @@ const ( ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" // OAuth endpoints - AuthorizeURL = "https://claude.ai/oauth/authorize" + AuthorizeURL = "https://claude.com/cai/oauth/authorize" TokenURL = "https://platform.claude.com/v1/oauth/token" RedirectURI = "https://platform.claude.com/oauth/code/callback" diff --git a/backend/internal/pkg/oauth/oauth_test.go b/backend/internal/pkg/oauth/oauth_test.go index 9e59f0f0b4..4e359392e7 100644 --- a/backend/internal/pkg/oauth/oauth_test.go +++ b/backend/internal/pkg/oauth/oauth_test.go @@ -1,11 +1,36 @@ package oauth import ( + "strings" "sync" "testing" "time" ) +func TestAuthorizeURLMatchesClaudeCodeCLI(t *testing.T) { + const want = "https://claude.com/cai/oauth/authorize" + if AuthorizeURL != want { + t.Fatalf("AuthorizeURL = %q, want %q", AuthorizeURL, want) + } + + authURL := BuildAuthorizationURL("state-value", "challenge-value", ScopeOAuth) + if !strings.HasPrefix(authURL, want+"?") { + t.Fatalf("BuildAuthorizationURL() = %q, want prefix %q", authURL, want+"?") + } + for _, part := range []string{ + "code=true", + "client_id=" + ClientID, + "response_type=code", + "code_challenge=challenge-value", + "code_challenge_method=S256", + "state=state-value", + } { + if !strings.Contains(authURL, part) { + t.Fatalf("BuildAuthorizationURL() missing %q\nURL: %s", part, authURL) + } + } +} + func TestSessionStore_Stop_Idempotent(t *testing.T) { store := NewSessionStore() diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 7c025c6563..172021b8ea 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -57,17 +57,6 @@ var codexOfficialClientOriginators = map[string]bool{ "codex_sdk_ts": true, // TypeScript SDK } -// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。 -// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。 -// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。 -func IsBrowserUserAgent(userAgent string) bool { - ua := strings.TrimSpace(userAgent) - if ua == "" { - return false - } - return strings.HasPrefix(strings.ToLower(ua), "mozilla/") -} - // IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request func IsCodexCLIRequest(userAgent string) bool { ua := normalizeCodexClientHeader(userAgent) @@ -255,6 +244,85 @@ func canonicalizeCodexOriginator(name string) string { return name } +// CodexCLIOriginator 是 codex-rs 客户端的历史默认 originator,保留用于兼容识别。 +const CodexCLIOriginator = "codex_cli_rs" + +// CodexDefaultOriginator 是网关默认使用的 Codex TUI originator。 +const CodexDefaultOriginator = "codex-tui" + +// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。 +// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4 +// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。 +// 取不到(非 Codex 形态 UA)时返回空串。 +func CodexUserAgentVersion(userAgent string) string { + ua := strings.TrimSpace(userAgent) + slash := strings.IndexByte(ua, '/') + if slash <= 0 { + return "" + } + rest := ua[slash+1:] + if space := strings.IndexByte(rest, ' '); space >= 0 { + rest = rest[:space] + } + return strings.TrimSpace(rest) +} + +// SetCodexUserAgentVersion 用 version 重建 Codex 形态 UA 中的版本声明,其余部分 +// (客户端名、OS / 架构 / 终端指纹)原样保留;UA 不是 `{client}/{version}` 形态时返回空串, +// 由调用方决定整体回退。 +// +// 尾部官方客户端标识组 `(name; version)` 与首段是同一个版本声明的两个出口 +// (CODEX_INTERNAL_ORIGINATOR_OVERRIDE 场景,如 `cccc/0.142.0 ... (codex-tui; 0.142.0)`), +// 必须一并更新,否则会拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。 +// 仅在括号组确为官方客户端标识时才改写,避免误伤 OS 组(如 `(Ubuntu 22.4.0; x86_64)`)。 +func SetCodexUserAgentVersion(userAgent, version string) string { + ua := strings.TrimSpace(userAgent) + version = strings.TrimSpace(version) + if version == "" { + return "" + } + slash := strings.IndexByte(ua, '/') + if slash <= 0 { + return "" + } + client := strings.TrimSpace(ua[:slash]) + if client == "" { + return "" + } + rest := ua[slash+1:] + tail := "" + if space := strings.IndexByte(rest, ' '); space >= 0 { + tail = rest[space:] + } else if strings.TrimSpace(rest) == "" { + // `client/` 没有版本段,不是可重建的 Codex 形态。 + return "" + } + return rewriteCodexUATrailerVersion(client+"/"+version+tail, version) +} + +// rewriteCodexUATrailerVersion 把尾部官方客户端标识组 `(name; version)` 的版本改成 version。 +// 括号组缺少 `;` 分隔的版本、或 name 不是官方 originator 时原样返回。 +func rewriteCodexUATrailerVersion(ua, version string) string { + open := strings.LastIndex(ua, "(") + if open < 0 { + return ua + } + closeIdx := strings.Index(ua[open+1:], ")") + if closeIdx < 0 { + return ua + } + inner := ua[open+1 : open+1+closeIdx] + semi := strings.Index(inner, ";") + if semi < 0 { + return ua + } + name := strings.TrimSpace(inner[:semi]) + if name == "" || !IsCodexOfficialClientOriginator(name) { + return ua + } + return ua[:open+1] + name + "; " + version + ua[open+1+closeIdx:] +} + // codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。 var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) diff --git a/backend/internal/pkg/openai/request_codex_version_test.go b/backend/internal/pkg/openai/request_codex_version_test.go new file mode 100644 index 0000000000..daf12fa8fe --- /dev/null +++ b/backend/internal/pkg/openai/request_codex_version_test.go @@ -0,0 +1,46 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCodexUserAgentVersion(t *testing.T) { + require.Equal(t, "0.146.0", CodexUserAgentVersion("codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color")) + require.Equal(t, "0.146.0", CodexUserAgentVersion(" codex_cli_rs/0.146.0 ")) + // 预发布后缀原样保留:出站 version 头必须与 UA 版本段逐字一致。 + require.Equal(t, "0.147.0-alpha.4", CodexUserAgentVersion("codex-tui/0.147.0-alpha.4 (Mac OS X 14.0; arm64) iTerm")) + // 非 `{client}/{version}` 形态取不到版本段。 + require.Empty(t, CodexUserAgentVersion("curl 8.7.1")) + require.Empty(t, CodexUserAgentVersion("/0.146.0")) + require.Empty(t, CodexUserAgentVersion("")) +} + +// 管理员在面板 / 账号上配置的 UA 填写于某个历史版本,逐字沿用会把出站身份钉死在陈旧版本上。 +// 重建只动版本声明,OS / 架构 / 终端指纹必须原样保留——那是该配置项唯一不可替代的价值。 +func TestSetCodexUserAgentVersion(t *testing.T) { + require.Equal(t, + "codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color", + SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color", "0.146.0"), + ) + require.Equal(t, "codex_cli_rs/0.146.0", SetCodexUserAgentVersion("codex_cli_rs/0.1.0", "0.146.0")) + + // 尾部官方客户端标识组与首段是同一个版本声明的两个出口,必须一并更新, + // 否则拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。 + require.Equal(t, + "cccc/0.146.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.146.0)", + SetCodexUserAgentVersion("cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", "0.146.0"), + ) + // OS 括号组不是客户端标识,不得被误改。 + require.Equal(t, + "codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64)", + SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64)", "0.146.0"), + ) + + // 无法重建时返回空串,由调用方决定整体回退,绝不拼出畸形身份。 + require.Empty(t, SetCodexUserAgentVersion("not-a-codex-client", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("/0.1.0", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/0.1.0", "")) +} diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go index e437cae342..3e135e3eb9 100644 --- a/backend/internal/pkg/proxyutil/dialer.go +++ b/backend/internal/pkg/proxyutil/dialer.go @@ -16,10 +16,29 @@ import ( "net/http" "net/url" "strings" + "time" "golang.org/x/net/proxy" ) +const ( + // socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。 + socks5DialTimeout = 10 * time.Second + // socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。 + socks5DialKeepAlive = 30 * time.Second +) + +// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。 +// +// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时), +// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会 +// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径 +// 无效,必须在这里补上。 +var socks5ForwardDialer = &net.Dialer{ + Timeout: socks5DialTimeout, + KeepAlive: socks5DialKeepAlive, +} + // ConfigureTransportProxy 根据代理 URL 配置 Transport // // 支持的协议: @@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error return nil case "socks5", "socks5h": - dialer, err := proxy.FromURL(proxyURL, proxy.Direct) + dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer) if err != nil { return fmt.Errorf("create socks5 dialer: %w", err) } diff --git a/backend/internal/pkg/proxyutil/dialer_timeout_test.go b/backend/internal/pkg/proxyutil/dialer_timeout_test.go new file mode 100644 index 0000000000..212bf0adac --- /dev/null +++ b/backend/internal/pkg/proxyutil/dialer_timeout_test.go @@ -0,0 +1,58 @@ +package proxyutil + +import ( + "context" + "errors" + "net" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var errStub = errors.New("stub dial") + +// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext, +// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer, +// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。 +func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) { + require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0)) + require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout) + require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive) +} + +func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) { + for _, scheme := range []string{"socks5", "socks5h"} { + t.Run(scheme, func(t *testing.T) { + proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080") + require.NoError(t, err) + + transport := &http.Transport{} + require.NoError(t, ConfigureTransportProxy(transport, proxyURL)) + require.NotNil(t, transport.DialContext) + require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy") + }) + } +} + +// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。 +func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) { + proxyURL, err := url.Parse("http://127.0.0.1:8080") + require.NoError(t, err) + + called := false + transport := &http.Transport{} + transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) { + called = true + return nil, errStub + } + + require.NoError(t, ConfigureTransportProxy(transport, proxyURL)) + require.NotNil(t, transport.Proxy) + require.NotNil(t, transport.DialContext) + + _, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1") + require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext") +} diff --git a/backend/internal/pkg/redissession/store.go b/backend/internal/pkg/redissession/store.go new file mode 100644 index 0000000000..3909fceecc --- /dev/null +++ b/backend/internal/pkg/redissession/store.go @@ -0,0 +1,125 @@ +// Package redissession provides a multi-instance OAuth session backend. +package redissession + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +var ErrNotConfigured = errors.New("redis session store not configured") + +// Store persists JSON sessions and single-use markers under one namespace. +type Store struct { + rdb *redis.Client + prefix string + ttl time.Duration +} + +func New(rdb *redis.Client, prefix string, ttl time.Duration) *Store { + if ttl <= 0 { + ttl = 30 * time.Minute + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "oauth:session" + } + if !strings.HasSuffix(prefix, ":") { + prefix += ":" + } + return &Store{rdb: rdb, prefix: prefix, ttl: ttl} +} + +func (s *Store) dataKey(id string) string { return s.prefix + strings.TrimSpace(id) } +func (s *Store) usedKey(id string) string { return s.prefix + "used:" + strings.TrimSpace(id) } + +func (s *Store) Set(ctx context.Context, id string, value any) error { + if s == nil || s.rdb == nil { + return ErrNotConfigured + } + id = strings.TrimSpace(id) + if id == "" { + return errors.New("session id is required") + } + if ctx == nil { + ctx = context.Background() + } + raw, err := json.Marshal(value) + if err != nil { + return err + } + return s.rdb.Set(ctx, s.dataKey(id), raw, s.ttl).Err() +} + +func (s *Store) Get(ctx context.Context, id string, dest any) (bool, error) { + if s == nil || s.rdb == nil { + return false, ErrNotConfigured + } + id = strings.TrimSpace(id) + if id == "" { + return false, nil + } + if ctx == nil { + ctx = context.Background() + } + raw, err := s.rdb.Get(ctx, s.dataKey(id)).Bytes() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, err + } + if err := json.Unmarshal(raw, dest); err != nil { + return false, err + } + return true, nil +} + +func (s *Store) Delete(ctx context.Context, id string) error { + if s == nil || s.rdb == nil { + return ErrNotConfigured + } + id = strings.TrimSpace(id) + if id == "" { + return nil + } + if ctx == nil { + ctx = context.Background() + } + return s.rdb.Del(ctx, s.dataKey(id), s.usedKey(id)).Err() +} + +// TryConsume returns true only for the first claim while the session exists. +func (s *Store) TryConsume(ctx context.Context, id string) (bool, error) { + if s == nil || s.rdb == nil { + return false, ErrNotConfigured + } + id = strings.TrimSpace(id) + if id == "" { + return false, nil + } + if ctx == nil { + ctx = context.Background() + } + ttl := s.ttl + if remaining, err := s.rdb.TTL(ctx, s.dataKey(id)).Result(); err == nil && remaining > 0 { + ttl = remaining + } + ok, err := s.rdb.SetNX(ctx, s.usedKey(id), "1", ttl).Result() + if err != nil || !ok { + return ok, err + } + exists, err := s.rdb.Exists(ctx, s.dataKey(id)).Result() + if err != nil { + return false, err + } + if exists == 0 { + _ = s.rdb.Del(ctx, s.usedKey(id)).Err() + return false, nil + } + return true, nil +} diff --git a/backend/internal/pkg/redissession/store_test.go b/backend/internal/pkg/redissession/store_test.go new file mode 100644 index 0000000000..ad5832fb84 --- /dev/null +++ b/backend/internal/pkg/redissession/store_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package redissession + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestStoreRoundTripAndSingleUse(t *testing.T) { + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + store := New(rdb, "oauth:test", time.Minute) + ctx := context.Background() + + require.NoError(t, store.Set(ctx, "sid", map[string]string{"state": "state"})) + var got map[string]string + ok, err := store.Get(ctx, "sid", &got) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "state", got["state"]) + + ok, err = store.TryConsume(ctx, "sid") + require.NoError(t, err) + require.True(t, ok) + ok, err = store.TryConsume(ctx, "sid") + require.NoError(t, err) + require.False(t, ok) + + require.NoError(t, store.Delete(ctx, "sid")) + ok, err = store.Get(ctx, "sid", &got) + require.NoError(t, err) + require.False(t, ok) +} diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index da9e748b19..a100b2ccaa 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -149,6 +149,7 @@ type UserUsageTrendPoint struct { type UserSpendingRankingItem struct { UserID int64 `json:"user_id"` Email string `json:"email"` + Username string `json:"username"` ActualCost float64 `json:"actual_cost"` // 实际扣除 Requests int64 `json:"requests"` Tokens int64 `json:"tokens"` @@ -273,13 +274,14 @@ type UsageLogFilters struct { RequestID string Model string // ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics. - ModelFilterSource string - RequestType *int16 - Stream *bool - BillingType *int8 - BillingMode string - StartTime *time.Time - EndTime *time.Time + ModelFilterSource string + RequestType *int16 + Stream *bool + BillingType *int8 + BillingMode string + UpstreamModelMismatch *bool + StartTime *time.Time + EndTime *time.Time // ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging. ExactTotal bool } diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index d8580ebab0..44e4772d44 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -15,9 +15,14 @@ const ( CLITokenAuthHeader = "x-xai-token-auth" CLITokenAuthValue = "xai-grok-cli" CLIClientVersionHeader = "x-grok-client-version" + // CLIClientVersion is the one place the pinned Grok CLI version lives. The + // 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.93" - CLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" + CLIClientVersion = "0.2.114" + // 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)" BillingWeeklyPath = "/billing?format=credits" BillingMonthlyPath = "/billing" @@ -40,14 +45,22 @@ type BillingProductUsage struct { } // BillingConfig is the nested config object from /v1/billing responses. +// Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute +// money fields typically appear on the credits (prepaid/on-demand) or monthly +// (limit/used) responses. type BillingConfig struct { - CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"` - CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"` - ProductUsage []BillingProductUsage `json:"productUsage,omitempty"` - MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"` - Used json.RawMessage `json:"used,omitempty"` - BillingPeriodStart string `json:"billingPeriodStart,omitempty"` - BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"` + CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"` + CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"` + ProductUsage []BillingProductUsage `json:"productUsage,omitempty"` + MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"` + Used json.RawMessage `json:"used,omitempty"` + OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"` + OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"` + PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"` + IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"` + TopUpMethod string `json:"topUpMethod,omitempty"` + BillingPeriodStart string `json:"billingPeriodStart,omitempty"` + BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"` } // BillingPayload is the top-level body from /v1/billing. @@ -62,6 +75,8 @@ type BillingProductSummary struct { } // BillingSummary is the merged weekly + monthly billing view. +// Cents fields remain the authoritative monthly numbers; dollar fields are the +// operator-facing absolute money view (prepaid / on-demand / monthly $). type BillingSummary struct { PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown UsagePercent *float64 `json:"usage_percent,omitempty"` @@ -74,17 +89,26 @@ type BillingSummary struct { BillingPeriodStart string `json:"billing_period_start,omitempty"` BillingPeriodEnd string `json:"billing_period_end,omitempty"` UsedPercent *float64 `json:"used_percent,omitempty"` - Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | "" - StatusCode int `json:"status_code,omitempty"` - WeeklyStatusCode int `json:"weekly_status_code,omitempty"` - MonthlyStatusCode int `json:"monthly_status_code,omitempty"` - Source string `json:"source,omitempty"` - FetchedAt string `json:"fetched_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"` - MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"` - Partial bool `json:"partial,omitempty"` - FailedWindows []string `json:"failed_windows,omitempty"` + // Absolute money (USD). Prepaid/on-demand come from credits probe as dollars. + // MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display. + PrepaidBalance *float64 `json:"prepaid_balance,omitempty"` + MonthlyLimit *float64 `json:"monthly_limit,omitempty"` + MonthlyUsed *float64 `json:"monthly_used,omitempty"` + OnDemandCap *float64 `json:"on_demand_cap,omitempty"` + OnDemandUsed *float64 `json:"on_demand_used,omitempty"` + TopUpMethod string `json:"top_up_method,omitempty"` + IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"` + Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | "" + StatusCode int `json:"status_code,omitempty"` + WeeklyStatusCode int `json:"weekly_status_code,omitempty"` + MonthlyStatusCode int `json:"monthly_status_code,omitempty"` + Source string `json:"source,omitempty"` + FetchedAt string `json:"fetched_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"` + MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"` + Partial bool `json:"partial,omitempty"` + FailedWindows []string `json:"failed_windows,omitempty"` } // BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy. @@ -124,7 +148,7 @@ func ApplyCLIBillingHeaders(req *http.Request, accessToken string) { req.Header.Set("Content-Type", "application/json") req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue) req.Header.Set(CLIClientVersionHeader, CLIClientVersion) - req.Header.Set("User-Agent", CLIUserAgent) + req.Header.Set("User-Agent", billingCLIUserAgent) } // ParseBillingPayload unmarshals a billing API response body. @@ -149,18 +173,15 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { periodType := resolvePeriodType(period) creditUsage := cloneFloat(config.CreditUsagePercent) + // Weekly period bounds must not fall back to monthly billing period ends — + // that would park accounts on a multi-week horizon when weekly UsagePercent + // is high (scheduler seven_day uses PeriodEnd). periodStart := "" periodEnd := "" if period != nil { periodStart = strings.TrimSpace(period.Start) periodEnd = strings.TrimSpace(period.End) } - if periodStart == "" { - periodStart = strings.TrimSpace(config.BillingPeriodStart) - } - if periodEnd == "" { - periodEnd = strings.TrimSpace(config.BillingPeriodEnd) - } products := make([]BillingProductSummary, 0, len(config.ProductUsage)) for _, item := range config.ProductUsage { @@ -176,6 +197,11 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { monthlyLimit := parseCentValue(config.MonthlyLimit) used := parseCentValue(config.Used) + // Absolute money on credits responses is dollar-denominated ({"val": 12}). + // Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents). + prepaid := parseCentValue(config.PrepaidBalance) + onDemandCap := parseCentValue(config.OnDemandCap) + onDemandUsed := parseCentValue(config.OnDemandUsed) billingStart := strings.TrimSpace(config.BillingPeriodStart) billingEnd := strings.TrimSpace(config.BillingPeriodEnd) @@ -195,7 +221,7 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { usedPercent = &v } - hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 + hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "") if !hasWeekly && !hasMonthly { return nil @@ -225,6 +251,24 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary { summary.BillingPeriodEnd = billingEnd } summary.UsedPercent = usedPercent + summary.PrepaidBalance = prepaid + if onDemandCap != nil { + summary.OnDemandCap = onDemandCap + } + if onDemandUsed != nil { + summary.OnDemandUsed = onDemandUsed + } + // Expose monthly cents as dollars for UI absolute rows. + if monthlyLimit != nil { + v := *monthlyLimit / 100 + summary.MonthlyLimit = &v + } + if used != nil { + v := *used / 100 + summary.MonthlyUsed = &v + } + summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod) + summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser summary.Plan = resolvePlan(monthlyLimit) return summary } @@ -254,6 +298,22 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK out.PeriodStart = weekly.PeriodStart out.PeriodEnd = weekly.PeriodEnd out.ProductUsage = weekly.ProductUsage + // Absolute prepaid / on-demand usually ride the credits (weekly) response. + if weekly.PrepaidBalance != nil { + out.PrepaidBalance = weekly.PrepaidBalance + } + if weekly.OnDemandCap != nil { + out.OnDemandCap = weekly.OnDemandCap + } + if weekly.OnDemandUsed != nil { + out.OnDemandUsed = weekly.OnDemandUsed + } + if weekly.TopUpMethod != "" { + out.TopUpMethod = weekly.TopUpMethod + } + if weekly.IsUnifiedBillingUser { + out.IsUnifiedBillingUser = true + } out.WeeklyUpdatedAt = now } if monthlyOK && monthly != nil { @@ -266,6 +326,15 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK out.BillingPeriodStart = monthly.BillingPeriodStart out.BillingPeriodEnd = monthly.BillingPeriodEnd out.UsedPercent = monthly.UsedPercent + out.MonthlyLimit = monthly.MonthlyLimit + out.MonthlyUsed = monthly.MonthlyUsed + // Monthly probe may also carry on-demand cap when credits omitted it. + if monthly.OnDemandCap != nil && out.OnDemandCap == nil { + out.OnDemandCap = monthly.OnDemandCap + } + if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil { + out.OnDemandUsed = monthly.OnDemandUsed + } out.Plan = monthly.Plan out.MonthlyUpdatedAt = now } diff --git a/backend/internal/pkg/xai/billing_test.go b/backend/internal/pkg/xai/billing_test.go index b508d5494f..3dbfc89bf2 100644 --- a/backend/internal/pkg/xai/billing_test.go +++ b/backend/internal/pkg/xai/billing_test.go @@ -48,7 +48,11 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { "config": { "currentPeriod": {"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"}, "creditUsagePercent": 2.0, - "productUsage": [{"product":"Api","usagePercent":2.0}] + "productUsage": [{"product":"Api","usagePercent":2.0}], + "prepaidBalance": {"val": 12}, + "onDemandCap": {"val": 100}, + "onDemandUsed": {"val": 5}, + "isUnifiedBillingUser": true } }`) monthlyBody := []byte(`{ @@ -72,10 +76,16 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { require.Equal(t, "weekly", weekly.PeriodType) require.InDelta(t, 2.0, *weekly.UsagePercent, 1e-9) require.Equal(t, "Api", weekly.ProductUsage[0].Product) + require.InDelta(t, 12, *weekly.PrepaidBalance, 1e-9) + require.InDelta(t, 100, *weekly.OnDemandCap, 1e-9) + require.InDelta(t, 5, *weekly.OnDemandUsed, 1e-9) + require.True(t, weekly.IsUnifiedBillingUser) require.Equal(t, "SuperGrok", monthly.Plan) require.InDelta(t, 15000, *monthly.MonthlyLimitCents, 1e-9) require.InDelta(t, 78, *monthly.UsedCents, 1e-9) require.InDelta(t, 0.52, *monthly.UsedPercent, 1e-2) + require.InDelta(t, 150, *monthly.MonthlyLimit, 1e-9) + require.InDelta(t, 0.78, *monthly.MonthlyUsed, 1e-9) merged := MergeBillingProbeResult(nil, weekly, monthly, true, true) require.Equal(t, "weekly", merged.PeriodType) @@ -83,6 +93,10 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) { require.Equal(t, "SuperGrok", merged.Plan) require.InDelta(t, 15000, *merged.MonthlyLimitCents, 1e-9) require.Equal(t, "2026-08-01T00:00:00Z", merged.BillingPeriodEnd) + require.InDelta(t, 12, *merged.PrepaidBalance, 1e-9) + require.InDelta(t, 100, *merged.OnDemandCap, 1e-9) + require.InDelta(t, 150, *merged.MonthlyLimit, 1e-9) + require.InDelta(t, 0.78, *merged.MonthlyUsed, 1e-9) } func TestParseCentValueBareNumber(t *testing.T) { @@ -105,6 +119,27 @@ func TestBuildBillingSummaryMonthlyOnlyKeepsWeeklyUsageEmpty(t *testing.T) { require.InDelta(t, 50, *summary.UsedPercent, 1e-9) } +func TestBuildBillingSummaryWeeklyDoesNotInheritMonthlyPeriodEnd(t *testing.T) { + t.Parallel() + // Weekly usage without currentPeriod.end must not copy billingPeriodEnd (monthly). + payload, err := ParseBillingPayload([]byte(`{ + "config": { + "creditUsagePercent": 95.0, + "productUsage": [{"product":"Api","usagePercent":95.0}], + "billingPeriodStart": "2026-07-01T00:00:00Z", + "billingPeriodEnd": "2026-08-01T00:00:00Z", + "monthlyLimit": {"val": 15000}, + "used": {"val": 1000} + } + }`)) + require.NoError(t, err) + summary := BuildBillingSummary(payload.Config) + require.NotNil(t, summary) + require.Equal(t, "weekly", summary.PeriodType) + require.Equal(t, "", summary.PeriodEnd) + require.Equal(t, "2026-08-01T00:00:00Z", summary.BillingPeriodEnd) +} + func TestMergeBillingProbeResultRetainsFailedWindow(t *testing.T) { t.Parallel() previous := &BillingSummary{ diff --git a/backend/internal/pkg/xai/cli_identity.go b/backend/internal/pkg/xai/cli_identity.go new file mode 100644 index 0000000000..480e2e40af --- /dev/null +++ b/backend/internal/pkg/xai/cli_identity.go @@ -0,0 +1,79 @@ +package xai + +import ( + "net/http" + "os" + "strings" + + "golang.org/x/mod/semver" +) + +// Fixed Grok Build / CLI-chat-proxy client identity. +// These values are intentionally pinned in-binary (not scraped from live CLI). +// Operators may bump the version via XAI_GROK_CLI_VERSION without a release. +const ( + // CLIProxyHost is the hostname that requires the official CLI identity headers. + CLIProxyHost = "cli-chat-proxy.grok.com" + + // CLIStableVersion is the known-good minimum client version accepted by cli-chat-proxy. + CLIStableVersion = "0.2.93" + + // CLIVersionEnv is the optional operator override for CLIStableVersion. + CLIVersionEnv = "XAI_GROK_CLI_VERSION" + + // CLITokenAuth is required by cli-chat-proxy for Grok Build OAuth tokens. + CLITokenAuth = "xai-grok-cli" + + // CLIClientIdentifier is the x-grok-client-identifier value used by Grok shell/CLI. + CLIClientIdentifier = "grok-shell" + + // CLIClientMode is used by billing / quota probes on the CLI surface. + CLIClientMode = "cli" +) + +// ResolveCLIVersion returns a supported CLI client version. +// Empty or invalid overrides fall back to CLIClientVersion (the pinned +// preferred client pin in billing.go). CLIStableVersion is only the minimum +// accepted by IsSupportedCLIVersion, not the default identity we advertise. +func ResolveCLIVersion() string { + version := strings.TrimSpace(os.Getenv(CLIVersionEnv)) + if !IsSupportedCLIVersion(version) { + return CLIClientVersion + } + return version +} + +// IsSupportedCLIVersion reports whether version is a valid semver string at or +// above CLIStableVersion (prereleases below a higher release are rejected when +// they compare less than the stable pin). +func IsSupportedCLIVersion(version string) bool { + canonical := "v" + version + minimum := "v" + CLIStableVersion + return semver.IsValid(canonical) && + semver.Canonical(canonical) == canonical && + semver.Compare(canonical, minimum) >= 0 +} + +// CLIUserAgent builds the workspace-style User-Agent for a CLI client version. +func CLIUserAgent(version string) string { + if strings.TrimSpace(version) == "" { + version = CLIClientVersion + } + return "xai-grok-workspace/" + version +} + +// ApplyCLIProxyHeaders stamps the fixed Grok CLI identity when the request +// targets cli-chat-proxy. Direct api.x.ai traffic is left unchanged. +func ApplyCLIProxyHeaders(req *http.Request) { + if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), CLIProxyHost) { + return + } + if req.Header == nil { + req.Header = make(http.Header) + } + version := ResolveCLIVersion() + req.Header.Set("X-XAI-Token-Auth", CLITokenAuth) + req.Header.Set("x-grok-client-version", version) + req.Header.Set("x-grok-client-identifier", CLIClientIdentifier) + req.Header.Set("User-Agent", CLIUserAgent(version)) +} diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go new file mode 100644 index 0000000000..521df7483d --- /dev/null +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -0,0 +1,67 @@ +package xai + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) { + t.Setenv(CLIVersionEnv, "") + // Default advertise pin is CLIClientVersion; CLIStableVersion is only the floor. + require.Equal(t, CLIClientVersion, ResolveCLIVersion()) + require.True(t, IsSupportedCLIVersion(CLIClientVersion)) + require.True(t, IsSupportedCLIVersion(CLIStableVersion)) +} + +func TestResolveCLIVersionAcceptsValidOverride(t *testing.T) { + t.Setenv(CLIVersionEnv, "0.2.95-alpha.1") + require.Equal(t, "0.2.95-alpha.1", ResolveCLIVersion()) +} + +func TestResolveCLIVersionRejectsUnsafeOrTooOld(t *testing.T) { + for _, version := range []string{ + "0.2.92", + "0.2.93-beta.1", + "0.2.95\r\nX-Injected: true", + "0.2.093", + "0.3", + "1", + } { + t.Run(version, func(t *testing.T) { + t.Setenv(CLIVersionEnv, version) + require.Equal(t, CLIClientVersion, ResolveCLIVersion()) + }) + } +} + +func TestApplyCLIProxyHeaders(t *testing.T) { + t.Setenv(CLIVersionEnv, "") + + 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") + + ApplyCLIProxyHeaders(req) + + require.Equal(t, CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, CLIClientIdentifier, req.Header.Get("x-grok-client-identifier")) + require.Equal(t, CLITokenAuth, req.Header.Get("X-XAI-Token-Auth")) + require.Equal(t, CLIUserAgent(CLIClientVersion), req.Header.Get("User-Agent")) +} + +func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { + t.Setenv(CLIVersionEnv, "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") + + 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")) +} diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 3a65f32c51..c25f489317 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -1,28 +1,130 @@ package xai +import ( + "strings" + "sync/atomic" +) + +// runtimeMappingOpts holds operator-configured defaults applied when Grok +// accounts leave credentials.model_mapping empty. Updated from settings. +var runtimeMappingOpts atomic.Value // ModelMappingOptions +var runtimeMappingVersion atomic.Uint64 + +func init() { + runtimeMappingOpts.Store(ModelMappingOptions{}) + runtimeMappingVersion.Store(1) +} + +// SetRuntimeModelMappingOptions updates process-wide defaults used by +// DefaultModelMapping (e.g. after settings load). Safe for concurrent use. +func SetRuntimeModelMappingOptions(opts ModelMappingOptions) { + runtimeMappingOpts.Store(opts) + runtimeMappingVersion.Add(1) +} + +// RuntimeModelMappingVersion changes whenever runtime mapping options change. +// Account-level caches include it so settings updates take effect without a restart. +func RuntimeModelMappingVersion() uint64 { + return runtimeMappingVersion.Load() +} + +// RuntimeModelMappingOptions returns the last options set via SetRuntimeModelMappingOptions. +func RuntimeModelMappingOptions() ModelMappingOptions { + if v := runtimeMappingOpts.Load(); v != nil { + if opts, ok := v.(ModelMappingOptions); ok { + return opts + } + } + return ModelMappingOptions{} +} + // Model describes an xAI model in OpenAI-compatible /models shape. type Model struct { ID string `json:"id"` Object string `json:"object"` + Type string `json:"type,omitempty"` Created int64 `json:"created,omitempty"` OwnedBy string `json:"owned_by"` DisplayName string `json:"display_name,omitempty"` } +// 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" + +// Official Imagine model IDs (https://docs.x.ai/docs/models). +const ( + DefaultImagineImageQualityModel = "grok-imagine-image-quality" + DefaultImagineImageFastModel = "grok-imagine-image" + DefaultImagineVideoModel = "grok-imagine-video" + DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5" + DefaultImagineVideo15Model = "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. +type ModelMappingOptions struct { + // DefaultText is the target for empty models and optional cross-client maps. + // Empty → DefaultTextModel (grok-4.5). + DefaultText string + // EnableCrossClientMap merges gpt-*/codex-*/o*/claude-* → DefaultText. + EnableCrossClientMap bool +} + +func (o ModelMappingOptions) defaultText() string { + if t := strings.TrimSpace(o.DefaultText); t != "" { + return t + } + return DefaultTextModel +} + var defaultModels = []Model{ - {ID: "grok-4.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"}, - {ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"}, - {ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"}, - {ID: "grok-composer-2.5-fast", Object: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"}, - {ID: "grok-4.20-0309-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"}, - {ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"}, - {ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"}, - {ID: "grok-imagine", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine"}, - {ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"}, - {ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"}, - {ID: "grok-imagine-edit", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"}, - {ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"}, - {ID: "grok-imagine-video-1.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"}, + // Text + {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"}, + {ID: "grok-4.20-0309-non-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"}, + {ID: "grok-4.20-multi-agent-0309", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"}, + // 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: 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"}, +} + +// grokTextResponsesModelAliases is the source of truth for Grok text models +// accepted by the Responses path: client-facing / undated aliases → canonical +// upstream ID. Used by DefaultModelMapping and IsGrokTextResponsesModelID. +var grokTextResponsesModelAliases = map[string]string{ + "grok": DefaultTextModel, + "grok-latest": DefaultTextModel, + "grok-4.5": DefaultTextModel, + "grok-4.5-latest": DefaultTextModel, + "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-0.1": "grok-build-0.1", + "grok-composer-2.5-fast": "grok-composer-2.5-fast", + "grok-composer": "grok-composer-2.5-fast", + "composer-2.5": "grok-composer-2.5-fast", + "grok-4.20-reasoning": "grok-4.20-0309-reasoning", + "grok-4.20-0309-reasoning": "grok-4.20-0309-reasoning", + "grok-4.20-non-reasoning": "grok-4.20-0309-non-reasoning", + "grok-4.20-0309-non-reasoning": "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent": "grok-4.20-multi-agent-0309", + "grok-4.20-multi-agent-latest": "grok-4.20-multi-agent-0309", + "grok-4.20-multi-agent-0309": "grok-4.20-multi-agent-0309", } func DefaultModels() []Model { @@ -40,19 +142,163 @@ func DefaultModelIDs() []string { return ids } +// DefaultModelMapping returns native Grok/Imagine identity + aliases, using +// runtime options (default text model / optional cross-client wildcards). +// Does NOT enable gpt-*/claude-* unless SetRuntimeModelMappingOptions enables them. func DefaultModelMapping() map[string]string { - mapping := make(map[string]string, len(defaultModels)+5) + return ModelMappingWithOptions(RuntimeModelMappingOptions()) +} + +// ModelMappingWithOptions builds the default Grok mapping with optional +// cross-client wildcards and a configurable default text model. +func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string { + defaultText := opts.defaultText() + mapping := make(map[string]string, len(defaultModels)+len(grokTextResponsesModelAliases)+48) for _, model := range defaultModels { mapping[model.ID] = model.ID } - mapping["grok"] = "grok-4.5" - mapping["grok-latest"] = "grok-4.5" - mapping["grok-4.5-latest"] = "grok-4.5" - mapping["grok-build"] = "grok-build-0.1" - mapping["grok-build-latest"] = "grok-4.5" - mapping["grok-composer"] = "grok-composer-2.5-fast" - mapping["composer-2.5"] = "grok-composer-2.5-fast" - mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning" - mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning" + for alias, canonical := range grokTextResponsesModelAliases { + // Remap aliases that pointed at DefaultTextModel constant to runtime default. + if canonical == DefaultTextModel { + mapping[alias] = defaultText + } else { + mapping[alias] = canonical + } + } + // Imagine aliases / legacy IDs → official catalog. + mapping["grok-imagine"] = DefaultImagineImageQualityModel + mapping["grok-imagine-1"] = DefaultImagineImageQualityModel + // Backward-compatible client alias; xAI exposes image editing through the + // image-quality model rather than a separate grok-imagine-edit model. + mapping["grok-imagine-edit"] = DefaultImagineImageQualityModel + mapping["grok-imagine-image"] = DefaultImagineImageFastModel + mapping["grok-imagine-image-quality"] = DefaultImagineImageQualityModel + // 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-preview"] = DefaultImagineVideo15Model + // Informal alias only: + mapping["grok-video-1.5"] = DefaultImagineVideo15Model + + if opts.EnableCrossClientMap { + // Codex / OpenAI Responses client defaults (wildcard patterns). + mapping["gpt-*"] = defaultText + mapping["codex-*"] = defaultText + mapping["o1*"] = defaultText + mapping["o3*"] = defaultText + mapping["o4*"] = defaultText + // Claude Code defaults when operators intentionally enable bridging. + mapping["claude-*"] = defaultText + } + addGrokProviderPrefixedMappings(mapping) return mapping } + +func addGrokProviderPrefixedMappings(mapping map[string]string) { + snapshot := make(map[string]string, len(mapping)) + for key, value := range mapping { + snapshot[key] = value + } + for key, value := range snapshot { + if !isGrokNativeOrAlias(key) { + continue + } + for _, prefix := range []string{"xai/", "x-ai/", "grok/"} { + mapping[prefix+key] = value + } + } +} + +func isGrokNativeOrAlias(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + if model == "" || strings.Contains(model, "*") { + return false + } + return strings.HasPrefix(model, "grok") || + strings.HasPrefix(model, "imagine") || + strings.HasPrefix(model, "composer") +} + +// StripGrokProviderPrefix removes common provider prefixes accepted for +// xAI/Grok models, returning the native model ID. +func StripGrokProviderPrefix(model string) string { + trimmed := strings.TrimSpace(model) + lower := strings.ToLower(trimmed) + for _, prefix := range []string{"xai/", "x-ai/", "grok/"} { + if strings.HasPrefix(lower, prefix) { + return strings.TrimSpace(trimmed[len(prefix):]) + } + } + return trimmed +} + +// IsGrokModelID reports whether model looks like a native Grok/xAI model id +// (including aliases). Claude/OpenAI model names return false. +func IsGrokModelID(model string) bool { + normalized := strings.ToLower(StripGrokProviderPrefix(model)) + if normalized == "" { + return false + } + if strings.HasPrefix(normalized, "grok") { + return true + } + if strings.HasPrefix(normalized, "imagine") { + return true + } + 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 { + normalized := strings.ToLower(StripGrokProviderPrefix(model)) + _, ok := grokTextResponsesModelAliases[normalized] + return ok +} + +// ResolveGrokTextResponsesModelID canonicalizes a Grok text alias before upstream. +// empty or bare aliases that resolve via DefaultTextModel use defaultText when set. +func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string { + fallback := DefaultTextModel + if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" { + fallback = strings.TrimSpace(defaultText[0]) + } + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return fallback + } + normalized := strings.ToLower(StripGrokProviderPrefix(trimmed)) + if canonical, ok := grokTextResponsesModelAliases[normalized]; ok { + if canonical == DefaultTextModel { + return fallback + } + return canonical + } + return StripGrokProviderPrefix(trimmed) +} + +// ResolveDefaultTextModel returns defaultText (or DefaultTextModel) when model is empty. +func ResolveDefaultTextModel(model string, defaultText ...string) string { + if trimmed := strings.TrimSpace(model); trimmed != "" { + return trimmed + } + if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" { + return strings.TrimSpace(defaultText[0]) + } + return DefaultTextModel +} + +// CanonicalImagineVideoModel normalizes video model ids for pricing tables. +// Legacy "grok-imagine-video-1.5" shares the 1.5 price family with preview. +func CanonicalImagineVideoModel(model string) string { + m := strings.ToLower(StripGrokProviderPrefix(model)) + switch { + case m == "" || m == DefaultImagineVideoModel || m == "grok-imagine-video-preview": + return DefaultImagineVideoModel + case strings.HasPrefix(m, "grok-imagine-video-1.5") || m == "grok-video-1.5": + return DefaultImagineVideo15Model + default: + return m + } +} diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go new file mode 100644 index 0000000000..98aef08cfc --- /dev/null +++ b/backend/internal/pkg/xai/models_test.go @@ -0,0 +1,65 @@ +package xai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) { + original := RuntimeModelMappingOptions() + 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-build-0.1", mapping["grok-build"]) + require.Equal(t, DefaultTextModel, 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-preview"]) + require.Equal(t, "grok-4.5", mapping["xai/grok"]) + + // Cross-vendor wildcards must stay opt-in. + _, hasGPT := mapping["gpt-*"] + _, hasClaude := mapping["claude-*"] + require.False(t, hasGPT) + require.False(t, hasClaude) +} + +func TestModelMappingWithOptionsCrossClient(t *testing.T) { + t.Parallel() + mapping := ModelMappingWithOptions(ModelMappingOptions{ + DefaultText: "grok-4.3", + EnableCrossClientMap: true, + }) + require.Equal(t, "grok-4.3", mapping["grok"]) + require.Equal(t, "grok-4.3", mapping["gpt-*"]) + require.Equal(t, "grok-4.3", mapping["claude-*"]) + require.Equal(t, "grok-4.3", mapping["codex-*"]) +} + +func TestCanonicalImagineVideoModel(t *testing.T) { + t.Parallel() + require.Equal(t, DefaultImagineVideoModel, CanonicalImagineVideoModel("grok-imagine-video")) + require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5")) + require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5-preview")) + require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("xai/grok-video-1.5")) + require.Equal(t, "grok-imagine-video-2", CanonicalImagineVideoModel("grok-imagine-video-2")) +} + +func TestIsGrokModelID(t *testing.T) { + t.Parallel() + require.True(t, IsGrokModelID("grok-4.5")) + require.True(t, IsGrokModelID("x-ai/grok-4.3")) + require.False(t, IsGrokModelID("gpt-5")) + require.False(t, IsGrokModelID("claude-sonnet-4")) +} + +func TestResolveGrokTextResponsesModelID(t *testing.T) { + t.Parallel() + require.Equal(t, "grok-4.5", 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")) +} diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go index 07d2227c20..a59d65ddf6 100644 --- a/backend/internal/pkg/xai/oauth.go +++ b/backend/internal/pkg/xai/oauth.go @@ -1,33 +1,40 @@ package xai import ( + "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" + "log/slog" "net/url" "os" "strings" "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/redissession" "github.com/Wei-Shaw/sub2api/internal/util/logredact" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/redis/go-redis/v9" ) const ( - OAuthIssuer = "https://auth.x.ai" - DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration" - DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize" - DefaultTokenURL = OAuthIssuer + "/oauth2/token" - DefaultBaseURL = "https://api.x.ai/v1" - DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1" - DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828" - DefaultScope = "openid profile email offline_access grok-cli:access api:access" - DefaultRedirectURI = "http://127.0.0.1:56121/callback" - SessionTTL = 30 * time.Minute + OAuthIssuer = "https://auth.x.ai" + DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration" + DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize" + DefaultTokenURL = OAuthIssuer + "/oauth2/token" + DefaultBaseURL = "https://api.x.ai/v1" + DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1" + DefaultUSEast1BaseURL = "https://us-east-1.api.x.ai/v1" + DefaultUSWest2BaseURL = "https://us-west-2.api.x.ai/v1" + DefaultEUWest1BaseURL = "https://eu-west-1.api.x.ai/v1" + DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828" + DefaultScope = "openid profile email offline_access grok-cli:access api:access" + DefaultRedirectURI = "http://127.0.0.1:56121/callback" + SessionTTL = 30 * time.Minute EnvAuthorizeURL = "XAI_OAUTH_AUTHORIZE_URL" EnvTokenURL = "XAI_OAUTH_TOKEN_URL" @@ -56,32 +63,110 @@ type OAuthSession struct { ProxyURL string `json:"proxy_url,omitempty"` RedirectURI string `json:"redirect_uri"` CreatedAt time.Time `json:"created_at"` + + mu sync.Mutex + consumed bool } -// SessionStore manages xAI OAuth sessions in memory. +func (s *OAuthSession) TryConsume() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.consumed { + return false + } + s.consumed = true + return true +} + +// SessionStore manages xAI OAuth sessions with an optional Redis backend. type SessionStore struct { - mu sync.RWMutex - sessions map[string]*OAuthSession - stopOnce sync.Once - stopCh chan struct{} + mu sync.RWMutex + sessions map[string]*OAuthSession + localOnly map[string]struct{} + stopOnce sync.Once + stopCh chan struct{} + remote *redissession.Store +} + +type oauthSessionDTO struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + CodeChallenge string `json:"code_challenge"` + ClientID string `json:"client_id,omitempty"` + Scope string `json:"scope,omitempty"` + ProxyURL string `json:"proxy_url,omitempty"` + RedirectURI string `json:"redirect_uri"` + CreatedAt time.Time `json:"created_at"` } func NewSessionStore() *SessionStore { store := &SessionStore{ - sessions: make(map[string]*OAuthSession), - stopCh: make(chan struct{}), + sessions: make(map[string]*OAuthSession), + localOnly: make(map[string]struct{}), + stopCh: make(chan struct{}), } go store.cleanup() return store } +func NewRedisSessionStore(rdb *redis.Client) *SessionStore { + store := NewSessionStore() + if rdb != nil { + store.remote = redissession.New(rdb, "oauth:session:xai", SessionTTL) + } + return store +} + func (s *SessionStore) Set(sessionID string, session *OAuthSession) { + if session == nil { + return + } + var remoteErr error + if s != nil && s.remote != nil { + remoteErr = s.remote.Set(context.Background(), sessionID, oauthSessionDTO{ + State: session.State, CodeVerifier: session.CodeVerifier, CodeChallenge: session.CodeChallenge, + ClientID: session.ClientID, Scope: session.Scope, ProxyURL: session.ProxyURL, + RedirectURI: session.RedirectURI, CreatedAt: session.CreatedAt, + }) + } s.mu.Lock() defer s.mu.Unlock() s.sessions[sessionID] = session + if remoteErr != nil { + s.localOnly[sessionID] = struct{}{} + slog.Warn("xai oauth session Redis write failed; using process-local fallback", "error", remoteErr) + } else { + delete(s.localOnly, sessionID) + } } func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { + if s.isLocalOnly(sessionID) { + return s.getMemory(sessionID) + } + if s != nil && s.remote != nil { + var dto oauthSessionDTO + ok, err := s.remote.Get(context.Background(), sessionID, &dto) + if err != nil || !ok || time.Since(dto.CreatedAt) > SessionTTL { + return nil, false + } + session := &OAuthSession{ + State: dto.State, CodeVerifier: dto.CodeVerifier, CodeChallenge: dto.CodeChallenge, + ClientID: dto.ClientID, Scope: dto.Scope, ProxyURL: dto.ProxyURL, + RedirectURI: dto.RedirectURI, CreatedAt: dto.CreatedAt, + } + s.mu.Lock() + s.sessions[sessionID] = session + s.mu.Unlock() + return session, true + } + return s.getMemory(sessionID) +} + +func (s *SessionStore) getMemory(sessionID string) (*OAuthSession, bool) { s.mu.RLock() defer s.mu.RUnlock() session, ok := s.sessions[sessionID] @@ -95,9 +180,39 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { } func (s *SessionStore) Delete(sessionID string) { + if s != nil && s.remote != nil { + _ = s.remote.Delete(context.Background(), sessionID) + } s.mu.Lock() defer s.mu.Unlock() delete(s.sessions, sessionID) + delete(s.localOnly, sessionID) +} + +func (s *SessionStore) TryConsumeSession(sessionID string) bool { + if s == nil { + return false + } + if s.isLocalOnly(sessionID) { + return s.tryConsumeMemory(sessionID) + } + if s.remote != nil { + ok, err := s.remote.TryConsume(context.Background(), sessionID) + return err == nil && ok + } + return s.tryConsumeMemory(sessionID) +} + +func (s *SessionStore) isLocalOnly(sessionID string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.localOnly[sessionID] + return ok +} + +func (s *SessionStore) tryConsumeMemory(sessionID string) bool { + session, ok := s.getMemory(sessionID) + return ok && session.TryConsume() } func (s *SessionStore) Stop() { @@ -118,6 +233,7 @@ func (s *SessionStore) cleanup() { for id, session := range s.sessions { if time.Since(session.CreatedAt) > SessionTTL { delete(s.sessions, id) + delete(s.localOnly, id) } } s.mu.Unlock() @@ -611,6 +727,11 @@ func BuildVideoURLWithValidator(baseURL, requestID string, validator BaseURLVali if requestID == "" { return "", fmt.Errorf("request id is required") } + // requestID 由客户端提供并拼进上游 URL 的 path。PathEscape 之外再要求它不是 + // 纯点片段、不含控制字符,保证它只能是一个普通的路径片段。 + if requestID == "." || requestID == ".." || strings.ContainsAny(requestID, "\x00\r\n") { + return "", fmt.Errorf("invalid request id") + } return validatedBaseURL + "/videos/" + url.PathEscape(requestID), nil } diff --git a/backend/internal/pkg/xai/oauth_redis_fallback_test.go b/backend/internal/pkg/xai/oauth_redis_fallback_test.go new file mode 100644 index 0000000000..40023acd68 --- /dev/null +++ b/backend/internal/pkg/xai/oauth_redis_fallback_test.go @@ -0,0 +1,35 @@ +//go:build unit + +package xai + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestSessionStoreRedisFallbackIsLimitedToFailedWrites(t *testing.T) { + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr(), MaxRetries: -1}) + t.Cleanup(func() { _ = client.Close() }) + store := NewRedisSessionStore(client) + defer store.Stop() + session := func(state string) *OAuthSession { return &OAuthSession{State: state, CreatedAt: time.Now()} } + + store.Set("remote", session("remote")) + require.NoError(t, store.remote.Delete(context.Background(), "remote")) + _, ok := store.Get("remote") + require.False(t, ok, "a remote miss must not revive the stale local copy") + + mr.Close() + store.Set("local-only", session("local")) + got, ok := store.Get("local-only") + require.True(t, ok) + require.Equal(t, "local", got.State) + require.True(t, store.TryConsumeSession("local-only")) + require.False(t, store.TryConsumeSession("local-only")) +} diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 53c8983103..3919f4576b 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -255,6 +255,14 @@ func TestBuildResponsesURLWithValidatorUsesCallerPolicy(t *testing.T) { require.Equal(t, "http://grok.example.test/v1/responses", target) } +func TestValidateTrustedBaseURLAcceptsOfficialRegionalHosts(t *testing.T) { + for _, raw := range []string{DefaultUSEast1BaseURL, DefaultUSWest2BaseURL, DefaultEUWest1BaseURL} { + got, err := ValidateTrustedBaseURL(raw) + require.NoError(t, err, raw) + require.Equal(t, raw, got) + } +} + func TestBuildResponsesURLPreservesUnsafeOverrideCustomPath(t *testing.T) { t.Setenv(EnvAllowUnsafeURLOverrides, "true") @@ -338,8 +346,9 @@ func TestRuntimeSanityReportsInvalidOverridesWithoutSecrets(t *testing.T) { } func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { - t.Parallel() - + original := RuntimeModelMappingOptions() + 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"]) @@ -352,10 +361,13 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"]) require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"]) - require.Equal(t, "grok-imagine", mapping["grok-imagine"]) - require.Equal(t, "grok-imagine-image", mapping["grok-imagine-image"]) - require.Equal(t, "grok-imagine-image-quality", mapping["grok-imagine-image-quality"]) - require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"]) - require.Equal(t, "grok-imagine-video", mapping["grok-imagine-video"]) - require.Equal(t, "grok-imagine-video-1.5", mapping["grok-imagine-video-1.5"]) + require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine"]) + require.Equal(t, DefaultImagineImageFastModel, mapping["grok-imagine-image"]) + 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-preview"]) + _, hasGPT := mapping["gpt-*"] + require.False(t, hasGPT, "cross-client wildcards must be opt-in") } diff --git a/backend/internal/pkg/xai/quota.go b/backend/internal/pkg/xai/quota.go index fe269d1d84..11133a05b3 100644 --- a/backend/internal/pkg/xai/quota.go +++ b/backend/internal/pkg/xai/quota.go @@ -7,10 +7,14 @@ import ( "time" ) -const GrokFreeRolling24hTokenLimit int64 = 1_000_000 +// GrokFreeRolling24hTokenLimit is the operator soft-gate nominal Free allowance +// (rolling 24h). Soft-gate default matches this; upstream header limits may +// still report historical 1M/2M Free snapshots. +const GrokFreeRolling24hTokenLimit int64 = 500_000 var grokFreeRolling24hTokenLimits = map[int64]struct{}{ GrokFreeRolling24hTokenLimit: {}, + 1_000_000: {}, // Observed Free limit variants. 2_000_000: {}, // Legacy Free limit observed before July 2026. } @@ -61,11 +65,27 @@ var quotaHeaderAllowlist = []string{ "x-ratelimit-limit-tokens", "x-ratelimit-remaining-tokens", "x-ratelimit-reset-tokens", + "x-rate-limit-limit-requests", + "x-rate-limit-remaining-requests", + "x-rate-limit-reset-requests", + "x-rate-limit-limit-tokens", + "x-rate-limit-remaining-tokens", + "x-rate-limit-reset-tokens", "retry-after", "x-subscription-tier", "xai-subscription-tier", + "x-xai-subscription-tier", + "x-xai-user-tier", + "xai-user-tier", + "xai-tier", + "x-user-tier", + "x-plan-tier", + "x-subscription-plan", "x-entitlement-status", "xai-entitlement-status", + "x-xai-entitlement-status", + "x-xai-user-entitlement-status", + "x-user-entitlement-status", } func ParseQuotaHeaders(headers http.Header, statusCode int) *QuotaSnapshot { @@ -95,8 +115,24 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE if retryAfter := parseRetryAfter(headers.Get("retry-after")); retryAfter != nil { snapshot.RetryAfterSeconds = retryAfter } - snapshot.SubscriptionTier = firstHeader(headers, "xai-subscription-tier", "x-subscription-tier") - snapshot.EntitlementStatus = firstHeader(headers, "xai-entitlement-status", "x-entitlement-status") + snapshot.SubscriptionTier = firstHeader(headers, + "xai-subscription-tier", + "x-subscription-tier", + "x-xai-subscription-tier", + "x-xai-user-tier", + "xai-user-tier", + "xai-tier", + "x-user-tier", + "x-plan-tier", + "x-subscription-plan", + ) + snapshot.EntitlementStatus = firstHeader(headers, + "xai-entitlement-status", + "x-entitlement-status", + "x-xai-entitlement-status", + "x-xai-user-entitlement-status", + "x-user-entitlement-status", + ) for _, name := range quotaHeaderAllowlist { if value := strings.TrimSpace(headers.Get(name)); value != "" { @@ -121,11 +157,23 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE } func parseQuotaWindow(headers http.Header, dimension string) *QuotaWindow { + limitHeader := firstHeader(headers, + "x-ratelimit-limit-"+dimension, + "x-rate-limit-limit-"+dimension, + ) + remainingHeader := firstHeader(headers, + "x-ratelimit-remaining-"+dimension, + "x-rate-limit-remaining-"+dimension, + ) + resetHeader := firstHeader(headers, + "x-ratelimit-reset-"+dimension, + "x-rate-limit-reset-"+dimension, + ) window := &QuotaWindow{ - Limit: parseInt64Ptr(headers.Get("x-ratelimit-limit-" + dimension)), - Remaining: parseInt64Ptr(headers.Get("x-ratelimit-remaining-" + dimension)), + Limit: parseInt64Ptr(limitHeader), + Remaining: parseInt64Ptr(remainingHeader), } - if reset := parseResetHeader(headers.Get("x-ratelimit-reset-" + dimension)); reset != nil { + if reset := parseResetHeader(resetHeader); reset != nil { window.ResetUnix = reset window.ResetAt = time.Unix(*reset, 0).UTC().Format(time.RFC3339) } @@ -141,11 +189,27 @@ func parseResetHeader(raw string) *int64 { return nil } if value, err := strconv.ParseInt(raw, 10, 64); err == nil { - if value > 1_000_000_000_000 { + // xAI (and OpenAI-compatible upstreams) may express the reset as a + // millisecond epoch, a second epoch, or a *relative* number of seconds + // until reset (e.g. "60"). Disambiguate by magnitude, mirroring the + // Kiro reset parser, so a relative "60" is not misread as 1970-01-01. + switch { + case value >= 1_000_000_000_000: // milliseconds epoch → seconds value = value / 1000 + case value >= 1_000_000_000: // already a plausible unix-seconds epoch (>= 2001-09) + // keep as-is + default: // relative seconds from now + value = time.Now().Unix() + value } return &value } + if duration, err := time.ParseDuration(raw); err == nil && duration > 0 { + if duration < time.Second { + duration = time.Second + } + value := time.Now().Add(duration).Unix() + return &value + } if t, err := time.Parse(time.RFC3339, raw); err == nil { value := t.Unix() return &value diff --git a/backend/internal/pkg/xai/quota_test.go b/backend/internal/pkg/xai/quota_test.go index 983a87e84f..40dfde4c09 100644 --- a/backend/internal/pkg/xai/quota_test.go +++ b/backend/internal/pkg/xai/quota_test.go @@ -5,6 +5,7 @@ package xai import ( "net/http" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -41,6 +42,104 @@ func TestParseQuotaHeaders(t *testing.T) { require.NotContains(t, snapshot.Headers, "authorization") } +func TestParseQuotaHeadersAcceptsXAITierAliases(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("x-xai-user-tier", "supergrok-heavy") + headers.Set("x-xai-user-entitlement-status", "enabled") + + snapshot := ParseQuotaHeaders(headers, http.StatusOK) + require.NotNil(t, snapshot) + require.True(t, snapshot.HeadersObserved) + require.Equal(t, "supergrok-heavy", snapshot.SubscriptionTier) + require.Equal(t, "enabled", snapshot.EntitlementStatus) + require.Equal(t, "supergrok-heavy", snapshot.Headers["x-xai-user-tier"]) + require.Equal(t, "enabled", snapshot.Headers["x-xai-user-entitlement-status"]) +} + +func TestParseQuotaHeadersAcceptsRateLimitAliases(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("x-rate-limit-limit-tokens", "500000") + headers.Set("x-rate-limit-remaining-tokens", "100") + headers.Set("x-rate-limit-reset-tokens", "1893456000") + + snapshot := ParseQuotaHeaders(headers, http.StatusOK) + require.NotNil(t, snapshot) + require.NotNil(t, snapshot.Tokens) + require.Equal(t, int64(500000), *snapshot.Tokens.Limit) + require.Equal(t, int64(100), *snapshot.Tokens.Remaining) + require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix) + require.Contains(t, snapshot.Headers, "x-rate-limit-limit-tokens") +} + +func TestParseResetHeaderRelativeSecondsNotMisreadAsEpoch(t *testing.T) { + t.Parallel() + + headers := http.Header{} + // xAI may return the reset window as a relative number of seconds ("60"). + // It must resolve to ~now+60s, NOT 1970-01-01 (epoch 60). + headers.Set("x-ratelimit-reset-requests", "60") + headers.Set("x-ratelimit-remaining-requests", "0") + + before := time.Now().Unix() + snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests) + require.NotNil(t, snapshot) + require.NotNil(t, snapshot.Requests) + require.NotNil(t, snapshot.Requests.ResetUnix) + got := *snapshot.Requests.ResetUnix + require.GreaterOrEqual(t, got, before+59) + require.LessOrEqual(t, got, time.Now().Unix()+61) +} + +func TestParseResetHeaderDurationWindow(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("x-ratelimit-reset-requests", "6m0s") + headers.Set("x-ratelimit-remaining-requests", "0") + + before := time.Now().Unix() + snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests) + require.NotNil(t, snapshot) + require.NotNil(t, snapshot.Requests) + require.NotNil(t, snapshot.Requests.ResetUnix) + got := *snapshot.Requests.ResetUnix + require.GreaterOrEqual(t, got, before+359) + require.LessOrEqual(t, got, time.Now().Unix()+361) +} + +func TestParseResetHeaderSubsecondDurationCeilsToFutureSecond(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("x-rate-limit-reset-tokens", "250ms") + headers.Set("x-rate-limit-remaining-tokens", "0") + + before := time.Now().Unix() + snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests) + require.NotNil(t, snapshot) + require.NotNil(t, snapshot.Tokens) + require.NotNil(t, snapshot.Tokens.ResetUnix) + require.GreaterOrEqual(t, *snapshot.Tokens.ResetUnix, before) + require.LessOrEqual(t, *snapshot.Tokens.ResetUnix, time.Now().Unix()+2) +} + +func TestParseResetHeaderMillisecondsEpochNormalized(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("x-ratelimit-reset-tokens", "1893456000000") // ms epoch + headers.Set("x-ratelimit-remaining-tokens", "0") + + snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests) + require.NotNil(t, snapshot) + require.NotNil(t, snapshot.Tokens) + require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix) +} + func TestParseQuotaHeadersReturnsNilForMissingHeaders(t *testing.T) { t.Parallel() @@ -66,6 +165,8 @@ func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) { t.Parallel() require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit)) + require.True(t, IsGrokFreeRolling24hTokenLimit(500_000)) + require.True(t, IsGrokFreeRolling24hTokenLimit(1_000_000), "observed Free limit variants remain classifiable") require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable") require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000)) } diff --git a/backend/internal/pkg/xai/sso_device.go b/backend/internal/pkg/xai/sso_device.go index e533e394d2..825616b93c 100644 --- a/backend/internal/pkg/xai/sso_device.go +++ b/backend/internal/pkg/xai/sso_device.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/http/cookiejar" "net/url" "sort" "strconv" @@ -25,6 +26,7 @@ const ( SSOConversionTimeout = 90 * time.Second ssoMaxAuthBody = 2 << 20 + ssoMaxTokenLength = 16 << 10 ssoDefaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" ssoDefaultTokenTTL = 6 * time.Hour ) @@ -51,7 +53,7 @@ type SSODeviceOptions struct { type ssoDeviceFlow struct { client SSODeviceHTTPClient userAgent string - cookies map[string]string + cookieJar http.CookieJar sleep func(context.Context, time.Duration) error } @@ -80,11 +82,16 @@ func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOpti if sleep == nil { sleep = sleepContext } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + seedSSOCookies(jar, ssoToken) flow := &ssoDeviceFlow{ client: client, userAgent: userAgent, - cookies: map[string]string{"sso": ssoToken, "sso-rw": ssoToken}, + cookieJar: jar, sleep: sleep, } return flow.convert(ctx) @@ -253,7 +260,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8") request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") request.Header.Set("User-Agent", f.userAgent) - if cookie := f.cookieHeader(); cookie != "" { + if cookie := f.cookieHeader(request.URL); cookie != "" { request.Header.Set("Cookie", cookie) } if currentForm != nil { @@ -264,7 +271,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur if err != nil { return 0, currentURL, nil, err } - f.captureCookies(response) + f.captureCookies(request.URL, response) data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1)) _ = response.Body.Close() if readErr != nil { @@ -298,30 +305,49 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times") } -func (f *ssoDeviceFlow) captureCookies(response *http.Response) { +func seedSSOCookies(jar http.CookieJar, token string) { + if jar == nil { + return + } + for _, rawURL := range []string{SSOAccountsURL, OAuthIssuer + "/"} { + target, err := url.Parse(rawURL) + if err != nil { + continue + } + jar.SetCookies(target, []*http.Cookie{ + {Name: "sso", Value: token, Path: "/", Secure: true, HttpOnly: true}, + {Name: "sso-rw", Value: token, Path: "/", Secure: true, HttpOnly: true}, + }) + } +} + +func (f *ssoDeviceFlow) captureCookies(requestURL *url.URL, response *http.Response) { + if f == nil || f.cookieJar == nil || requestURL == nil || response == nil { + return + } + cookies := make([]*http.Cookie, 0) for _, cookie := range response.Cookies() { name := strings.TrimSpace(cookie.Name) value := strings.TrimSpace(cookie.Value) if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") { continue } - if cookie.MaxAge < 0 { - delete(f.cookies, name) - continue - } - f.cookies[name] = value + cookie.Name = name + cookie.Value = value + cookies = append(cookies, cookie) } + f.cookieJar.SetCookies(requestURL, cookies) } -func (f *ssoDeviceFlow) cookieHeader() string { - keys := make([]string, 0, len(f.cookies)) - for key := range f.cookies { - keys = append(keys, key) +func (f *ssoDeviceFlow) cookieHeader(requestURL *url.URL) string { + if f == nil || f.cookieJar == nil || requestURL == nil { + return "" } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, key := range keys { - parts = append(parts, key+"="+f.cookies[key]) + cookies := f.cookieJar.Cookies(requestURL) + sort.Slice(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name }) + parts := make([]string, 0, len(cookies)) + for _, cookie := range cookies { + parts = append(parts, cookie.Name+"="+cookie.Value) } return strings.Join(parts, "; ") } @@ -363,7 +389,11 @@ func NormalizeSSOToken(value string) string { } func sanitizeSSOToken(value string) string { - return strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value)) + value = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value)) + if len(value) > ssoMaxTokenLength { + return "" + } + return value } func DecodeJWTClaims(token string) map[string]any { diff --git a/backend/internal/pkg/xai/sso_device_test.go b/backend/internal/pkg/xai/sso_device_test.go index 27dff15f4d..cd3bae3aa3 100644 --- a/backend/internal/pkg/xai/sso_device_test.go +++ b/backend/internal/pkg/xai/sso_device_test.go @@ -6,6 +6,7 @@ import ( "context" "io" "net/http" + "net/http/cookiejar" "net/url" "strings" "testing" @@ -25,7 +26,7 @@ func (c *ssoDeviceFakeClient) Do(req *http.Request) (*http.Response, error) { switch req.URL.String() { case SSOAccountsURL: require.Equal(c.t, http.MethodGet, req.Method) - return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Path=/"}}, `{}`), nil + return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Domain=x.ai; Path=/"}}, `{}`), nil case SSODeviceURL: require.Equal(c.t, http.MethodPost, req.Method) values := readSSODeviceForm(c.t, req) @@ -92,6 +93,33 @@ func TestNormalizeSSOTokenAcceptsCookieHeader(t *testing.T) { require.Equal(t, "token-1", NormalizeSSOToken("Cookie: foo=bar; sso=token-1; sso-rw=token-2")) require.Equal(t, "token-2", NormalizeSSOToken("sso-rw=token-2; foo=bar")) require.Equal(t, "raw-token", NormalizeSSOToken(" raw-token ; ignored=1")) + require.Empty(t, NormalizeSSOToken(strings.Repeat("x", ssoMaxTokenLength+1))) +} + +func TestSSODeviceCookieJarHonorsDomainAndPath(t *testing.T) { + jar, err := cookiejar.New(nil) + require.NoError(t, err) + flow := &ssoDeviceFlow{cookieJar: jar} + accountsURL, err := url.Parse("https://accounts.x.ai/") + require.NoError(t, err) + authURL, err := url.Parse("https://auth.x.ai/oauth2/device/verify") + require.NoError(t, err) + + flow.captureCookies(accountsURL, ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": { + "host-only=accounts; Path=/", + "shared=all-xai; Domain=x.ai; Path=/", + "narrow=oauth-only; Domain=x.ai; Path=/oauth2", + }}, "")) + + authCookies := flow.cookieHeader(authURL) + require.NotContains(t, authCookies, "host-only=accounts") + require.Contains(t, authCookies, "shared=all-xai") + require.Contains(t, authCookies, "narrow=oauth-only") + + accountsCookies := flow.cookieHeader(accountsURL) + require.Contains(t, accountsCookies, "host-only=accounts") + require.Contains(t, accountsCookies, "shared=all-xai") + require.NotContains(t, accountsCookies, "narrow=oauth-only") } func ssoDeviceResponse(status int, header http.Header, body string) *http.Response { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 212523a4fb..010442eb6b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -56,8 +56,10 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_secondary_", "codex_5h_", "codex_7d_", + "codex_reset_credit_", "passive_usage_", "upstream_billing_probe", + "upstream_billing_rate_sync", "ollama_cloud_usage", } @@ -397,16 +399,29 @@ func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]i } func (r *accountRepository) Update(ctx context.Context, account *service.Account) error { - return r.updateAccount(ctx, account, nil) + return r.updateAccount(ctx, account, nil, nil, account.RateMultiplier) } -// UpdateWithUpstreamBillingProbeEnabled applies an explicit probe switch in the -// same row-lock transaction as the rest of an admin account edit. -func (r *accountRepository) UpdateWithUpstreamBillingProbeEnabled(ctx context.Context, account *service.Account, enabled bool) error { - return r.updateAccount(ctx, account, &enabled) +// UpdateWithAccountBillingSettings applies an admin account edit while +// preserving a concurrently probe-synchronized rate unless the request +// explicitly includes a manual rate. +func (r *accountRepository) UpdateWithAccountBillingSettings( + ctx context.Context, + account *service.Account, + probeEnabled *bool, + rateSyncEnabled *bool, + rateMultiplier *float64, +) error { + return r.updateAccount(ctx, account, probeEnabled, rateSyncEnabled, rateMultiplier) } -func (r *accountRepository) updateAccount(ctx context.Context, account *service.Account, explicitProbeEnabled *bool) error { +func (r *accountRepository) updateAccount( + ctx context.Context, + account *service.Account, + explicitProbeEnabled *bool, + explicitRateSyncEnabled *bool, + explicitRateMultiplier *float64, +) error { if account == nil { return nil } @@ -430,7 +445,14 @@ func (r *accountRepository) updateAccount(ctx context.Context, account *service. } } - updated, err := r.updateLockedAccount(ctx, client, account, explicitProbeEnabled) + updated, err := r.updateLockedAccount( + ctx, + client, + account, + explicitProbeEnabled, + explicitRateSyncEnabled, + explicitRateMultiplier, + ) if err != nil { return translatePersistenceError(err, service.ErrAccountNotFound, nil) } @@ -452,8 +474,15 @@ func (r *accountRepository) updateAccount(ctx context.Context, account *service. return nil } -func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbent.Client, account *service.Account, explicitProbeEnabled *bool) (*dbent.Account, error) { - extra, err := lockAndMergeAccountProbeExtra(ctx, client, account, explicitProbeEnabled) +func (r *accountRepository) updateLockedAccount( + ctx context.Context, + client *dbent.Client, + account *service.Account, + explicitProbeEnabled *bool, + explicitRateSyncEnabled *bool, + explicitRateMultiplier *float64, +) (*dbent.Account, error) { + extra, err := lockAndMergeAccountProbeExtra(ctx, client, account, explicitProbeEnabled, explicitRateSyncEnabled) if err != nil { return nil, err } @@ -478,8 +507,8 @@ func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbe SetSchedulable(schedulable). SetAutoPauseOnExpired(account.AutoPauseOnExpired) - if account.RateMultiplier != nil { - builder.SetRateMultiplier(*account.RateMultiplier) + if explicitRateMultiplier != nil { + builder.SetRateMultiplier(*explicitRateMultiplier) } if account.LoadFactor != nil { builder.SetLoadFactor(*account.LoadFactor) @@ -542,7 +571,13 @@ func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbe return builder.Save(ctx) } -func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, account *service.Account, explicitProbeEnabled *bool) (map[string]any, error) { +func lockAndMergeAccountProbeExtra( + ctx context.Context, + client *dbent.Client, + account *service.Account, + explicitProbeEnabled *bool, + explicitRateSyncEnabled *bool, +) (map[string]any, error) { credentials, err := json.Marshal(normalizeJSONMap(account.Credentials)) if err != nil { return nil, err @@ -569,6 +604,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac ), proxy_id IS NOT DISTINCT FROM $5, extra -> 'upstream_billing_probe_enabled', + extra -> 'upstream_billing_rate_sync_enabled', extra -> 'upstream_billing_probe', extra -> 'ollama_cloud_usage_session', extra -> 'ollama_cloud_usage_auto_refresh', @@ -593,6 +629,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac ollamaGroupIdentityUnchanged bool ollamaProxyIdentityUnchanged bool currentEnabled []byte + currentRateSyncEnabled []byte currentSnapshot []byte currentOllamaSession []byte currentOllamaAutoRefresh []byte @@ -603,6 +640,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac &ollamaGroupIdentityUnchanged, &ollamaProxyIdentityUnchanged, ¤tEnabled, + ¤tRateSyncEnabled, ¤tSnapshot, ¤tOllamaSession, ¤tOllamaAutoRefresh, @@ -617,6 +655,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac extra := copyJSONMap(normalizeJSONMap(account.Extra)) for _, key := range []string{ service.UpstreamBillingProbeEnabledExtraKey, + service.UpstreamBillingRateSyncEnabledExtraKey, service.UpstreamBillingProbeExtraKey, service.OllamaCloudUsageSessionExtraKey, service.OllamaCloudUsageAutoRefreshExtraKey, @@ -624,21 +663,53 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac } { delete(extra, key) } - probeExplicitlyDisabled := false - probeAccount := account.Platform == service.PlatformOpenAI && account.Type == service.AccountTypeAPIKey - if probeAccount && explicitProbeEnabled != nil { - extra[service.UpstreamBillingProbeEnabledExtraKey] = *explicitProbeEnabled - probeExplicitlyDisabled = !*explicitProbeEnabled - } else if probeAccount { + probeAccount := service.IsUpstreamBillingProbeIdentity(account.Platform, account.Type) + probeEnabled := false + probeEnabledPresent := false + if probeAccount { if enabled, ok, err := decodeAccountExtraJSON(currentEnabled); err != nil { return nil, err - } else if ok { - extra[service.UpstreamBillingProbeEnabledExtraKey] = enabled - if value, isBool := enabled.(bool); isBool && !value { - probeExplicitlyDisabled = true - } + } else if value, isBool := enabled.(bool); ok && isBool { + probeEnabled = value + probeEnabledPresent = true + } + if explicitProbeEnabled != nil { + probeEnabled = *explicitProbeEnabled + probeEnabledPresent = true } } + rateSyncEnabled := false + rateSyncEnabledPresent := false + if probeAccount { + if enabled, ok, err := decodeAccountExtraJSON(currentRateSyncEnabled); err != nil { + return nil, err + } else if value, isBool := enabled.(bool); ok && isBool { + rateSyncEnabled = value + rateSyncEnabledPresent = true + } + if explicitRateSyncEnabled != nil { + rateSyncEnabled = *explicitRateSyncEnabled + rateSyncEnabledPresent = true + } + if explicitProbeEnabled != nil && !*explicitProbeEnabled { + rateSyncEnabled = false + rateSyncEnabledPresent = true + } + // 同步依赖探测,方向是单向的:探测关闭(或探测键缺失)一律把同步归零。 + // 不做反向推导——由 rate_sync=true 推出 probe=true 会让一条"同步开、探测键 + // 缺失"的僵尸记录在任意一次无关编辑时静默打开周期性外呼。需要同时打开两个 + // 开关的调用方(管理端编辑)自己显式传 explicitProbeEnabled=true。 + if !probeEnabled { + rateSyncEnabled = false + } + if probeEnabledPresent { + extra[service.UpstreamBillingProbeEnabledExtraKey] = probeEnabled + } + if rateSyncEnabledPresent { + extra[service.UpstreamBillingRateSyncEnabledExtraKey] = rateSyncEnabled + } + } + probeExplicitlyDisabled := probeEnabledPresent && !probeEnabled if identityUnchanged && !probeExplicitlyDisabled { if snapshot, ok, err := decodeAccountExtraJSON(currentSnapshot); err != nil { return nil, err @@ -709,7 +780,7 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre credentials = $1::jsonb, extra = CASE -- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止 - -- 非 Ollama 账号的无变化持久化误清 openai 探测快照或重写 NULL extra。 + -- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。 WHEN platform IN ('openai', 'anthropic') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb @@ -720,15 +791,14 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre AND `+ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'")+` ) ) - THEN (CASE - WHEN platform = 'openai' THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - ELSE COALESCE(extra, '{}'::jsonb) - END) + THEN COALESCE(extra, '{}'::jsonb) + - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot' - WHEN platform = 'openai' - AND type = 'apikey' + -- 上游倍率探测已放宽到全部 API-key 平台:凭证变化即视为探测 + -- 身份变化,丢弃 stale 快照。 + WHEN type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' ELSE extra @@ -1107,6 +1177,7 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o SELECT id FROM accounts WHERE deleted_at IS NULL + AND schedulable = TRUE AND platform = ANY($1) AND id > $2` if options.ActiveOnly { @@ -2527,21 +2598,25 @@ func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot( ctx context.Context, account *service.Account, snapshot *service.UpstreamBillingProbeSnapshot, + rateMultiplier *float64, ) error { if account == nil || snapshot == nil { return service.ErrAccountNilInput } + if snapshot.Status != service.UpstreamBillingProbeStatusOK { + rateMultiplier = nil + } if dbent.TxFromContext(ctx) == nil { tx, err := r.client.Tx(ctx) if errors.Is(err, dbent.ErrTxStarted) { - return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot) + return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot, rateMultiplier) } if err != nil { return err } defer func() { _ = tx.Rollback() }() - if err := r.updateUpstreamBillingProbeSnapshotInTx(dbent.NewTxContext(ctx, tx), account, snapshot); err != nil { + if err := r.updateUpstreamBillingProbeSnapshotInTx(dbent.NewTxContext(ctx, tx), account, snapshot, rateMultiplier); err != nil { return err } if err := tx.Commit(); err != nil { @@ -2552,13 +2627,14 @@ func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot( r.syncSchedulerAccountSnapshot(ctx, account.ID) return nil } - return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot) + return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot, rateMultiplier) } func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx( ctx context.Context, account *service.Account, snapshot *service.UpstreamBillingProbeSnapshot, + rateMultiplier *float64, ) error { payload, err := json.Marshal(map[string]any{service.UpstreamBillingProbeExtraKey: snapshot}) if err != nil { @@ -2584,6 +2660,14 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx( if err != nil { return err } + var expectedRateSyncEnabled any + if account.Extra != nil { + expectedRateSyncEnabled = account.Extra[service.UpstreamBillingRateSyncEnabledExtraKey] + } + expectedRateSyncEnabledJSON, err := json.Marshal(expectedRateSyncEnabled) + if err != nil { + return err + } client := clientFromContext(ctx, r.client) proxyMatches, err := lockAndMatchProbeProxyIdentity(ctx, client, account) if err != nil { @@ -2598,7 +2682,16 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx( } result, err := client.ExecContext(ctx, ` UPDATE accounts - SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, updated_at = NOW() + SET + extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, + rate_multiplier = CASE + WHEN $10::numeric IS NOT NULL + AND extra @> '{"upstream_billing_probe_enabled": true}'::jsonb + AND extra @> '{"upstream_billing_rate_sync_enabled": true}'::jsonb + THEN $10::numeric + ELSE rate_multiplier + END, + updated_at = NOW() WHERE id = $2 AND platform = $3 AND type = $4 @@ -2606,8 +2699,9 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx( AND proxy_id IS NOT DISTINCT FROM $6 AND COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb AND COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb + AND COALESCE(extra -> 'upstream_billing_rate_sync_enabled', 'null'::jsonb) = $9::jsonb AND deleted_at IS NULL - `, string(payload), account.ID, account.Platform, account.Type, string(credentials), proxyID, string(expectedSnapshotJSON), string(expectedEnabledJSON)) + `, string(payload), account.ID, account.Platform, account.Type, string(credentials), proxyID, string(expectedSnapshotJSON), string(expectedEnabledJSON), string(expectedRateSyncEnabledJSON), rateMultiplier) if err != nil { return err } @@ -2838,8 +2932,8 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates args = append(args, pq.Array(ids)) idx++ if updates.ProbeEnabled != nil { - whereClause += " AND platform = $" + itoa(idx) + " AND type = $" + itoa(idx+1) - args = append(args, service.PlatformOpenAI, service.AccountTypeAPIKey) + whereClause += " AND type = $" + itoa(idx) + args = append(args, service.AccountTypeAPIKey) } query := "UPDATE accounts SET " + joinClauses(setClauses, ", ") + whereClause @@ -3381,7 +3475,6 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont FROM accounts WHERE deleted_at IS NULL AND status = 'active' - AND platform = 'openai' AND type = 'apikey' AND extra @> '{"upstream_billing_probe_enabled": true}'::jsonb ), parsed AS MATERIALIZED ( diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index fa4eed7f5c..55f4cd64db 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -327,6 +327,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus "expires_at": now.Add(30 * time.Minute).Format(time.RFC3339), }, }) + unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "grok-oauth-unschedulable-excluded", + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Credentials: map[string]any{"refresh_token": "refresh-unschedulable"}, + }) + s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx)) mustCreateAccount(s.T(), s.client, &service.Account{ Name: "grok-api-key-excluded", Platform: service.PlatformGrok, @@ -386,6 +394,7 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus first := firstPage.Accounts s.Require().Len(first, 2) s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID}) + s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID) options.AfterID = first[len(first)-1].ID secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options) diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go index 448eb42a69..868901584b 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go @@ -229,7 +229,7 @@ func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *te loaded, err := newAccountRepositoryWithSQL(tx.Client(), tx, nil).GetByID(ctx, account.ID) require.NoError(t, err) - merged, err := lockAndMergeAccountProbeExtra(ctx, tx.Client(), loaded, nil) + merged, err := lockAndMergeAccountProbeExtra(ctx, tx.Client(), loaded, nil, nil) require.NoError(t, err, "a NULL Ollama eligibility expression must scan as false") require.NotContains(t, merged, service.OllamaCloudUsageSessionExtraKey) diff --git a/backend/internal/repository/account_repo_temp_unsched_test.go b/backend/internal/repository/account_repo_temp_unsched_test.go index df1e7abf3b..b7604e6c3f 100644 --- a/backend/internal/repository/account_repo_temp_unsched_test.go +++ b/backend/internal/repository/account_repo_temp_unsched_test.go @@ -276,6 +276,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") + require.Contains(t, normalized, "schedulable = TRUE", + "permanently unschedulable accounts must not remain OAuth refresh candidates") require.Contains(t, normalized, "status = 'active'") // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选 require.Contains(t, normalized, "type IN ('oauth', 'setup-token')") diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_cas_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_cas_test.go index 4de2187915..f9fe019554 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_cas_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_cas_test.go @@ -41,8 +41,8 @@ func TestUpdateUpstreamBillingProbeSnapshotRequiresSameIdentityAndSnapshot(t *te WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}). AddRow("http", "127.0.0.1", 3128, "user", "pass", service.StatusActive)) - mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("WHERE id = $2")+`.*`+regexp.QuoteMeta("AND platform = $3")+`.*`+regexp.QuoteMeta("AND type = $4")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb")). - WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test","base_url":"http://127.0.0.1:8080"}`, int64(9), `{"status":"stale"}`, "null"). + mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("WHERE id = $2")+`.*`+regexp.QuoteMeta("AND platform = $3")+`.*`+regexp.QuoteMeta("AND type = $4")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_rate_sync_enabled', 'null'::jsonb) = $9::jsonb")). + WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test","base_url":"http://127.0.0.1:8080"}`, int64(9), `{"status":"stale"}`, "null", "null", nil). WillReturnResult(sqlmock.NewResult(0, tt.affected)) if tt.affected > 0 { mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). @@ -75,7 +75,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRequiresSameIdentityAndSnapshot(t *te } txCtx := dbent.NewTxContext(context.Background(), tx) - err = repo.UpdateUpstreamBillingProbeSnapshot(txCtx, account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}) + err = repo.UpdateUpstreamBillingProbeSnapshot(txCtx, account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, nil) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) @@ -98,8 +98,8 @@ func TestUpdateUpstreamBillingProbeSnapshotCommitsSnapshotAndOutboxAtomically(t t.Cleanup(func() { _ = client.Close() }) mock.ExpectBegin() - mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")). - WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "null"). + mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("rate_multiplier = CASE")+`.*`+regexp.QuoteMeta("THEN $10::numeric")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")). + WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "true", "true", 0.065). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). @@ -112,9 +112,19 @@ func TestUpdateUpstreamBillingProbeSnapshotCommitsSnapshotAndOutboxAtomically(t Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingRateSyncEnabledExtraKey: true, + }, } + rateMultiplier := 0.065 - err = repo.UpdateUpstreamBillingProbeSnapshot(context.Background(), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}) + err = repo.UpdateUpstreamBillingProbeSnapshot( + context.Background(), + account, + &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, + &rateMultiplier, + ) require.NoError(t, err) require.NoError(t, mock.ExpectationsWereMet()) @@ -148,7 +158,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRejectsChangedProxyIdentity(t *testin }, } repo := newAccountRepositoryWithSQL(client, db, nil) - err = repo.UpdateUpstreamBillingProbeSnapshot(dbent.NewTxContext(context.Background(), tx), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}) + err = repo.UpdateUpstreamBillingProbeSnapshot(dbent.NewTxContext(context.Background(), tx), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, nil) require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged) mock.ExpectRollback() @@ -166,7 +176,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRollsBackWhenOutboxFails(t *testing.T mock.ExpectBegin() mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")). - WithArgs(sqlmock.AnyArg(), int64(18), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "null"). + WithArgs(sqlmock.AnyArg(), int64(18), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "true", "true", 0.7). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).WillReturnError(errors.New("outbox failed")) mock.ExpectRollback() @@ -177,9 +187,19 @@ func TestUpdateUpstreamBillingProbeSnapshotRollsBackWhenOutboxFails(t *testing.T Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingRateSyncEnabledExtraKey: true, + }, } + rateMultiplier := 0.7 - err = repo.UpdateUpstreamBillingProbeSnapshot(context.Background(), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}) + err = repo.UpdateUpstreamBillingProbeSnapshot( + context.Background(), + account, + &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, + &rateMultiplier, + ) require.EqualError(t, err, "outbox failed") require.NoError(t, mock.ExpectationsWereMet()) diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go index 104771af03..e88579c1d0 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go @@ -153,3 +153,54 @@ func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testi } require.Equal(t, want, got) } + +// 探测资格放宽回归:任何 API-key 平台的启用账号都进入定时探测候选并按 +// 到期时间排序;OAuth 与未启用账号仍被排除。 +func TestListDueUpstreamBillingProbeAccountsIncludesAllAPIKeyPlatforms(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 26, 3, 0, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + insert := func(name, platform, accountType, nextProbeAt string) int64 { + t.Helper() + var id int64 + extra := fmt.Sprintf(`{ + "upstream_billing_probe_enabled": true, + "upstream_billing_probe": {"status": "ok", "next_probe_at": %q} + }`, nextProbeAt) + err := scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ($1, $2, $3, 'active', $4::jsonb) + RETURNING id + `, []any{name, platform, accountType, extra}, &id) + require.NoError(t, err) + return id + } + + openaiDue := insert("probe-openai-due", "openai", service.AccountTypeAPIKey, "2026-07-26T02:57:00Z") + anthropicDue := insert("probe-anthropic-due", "anthropic", service.AccountTypeAPIKey, "2026-07-26T02:58:00Z") + grokDue := insert("probe-grok-due", "grok", service.AccountTypeAPIKey, "2026-07-26T02:59:00Z") + // OAuth 账号即便误持有启用标记也不得入选。 + _ = insert("probe-grok-oauth-excluded", "grok", service.AccountTypeOAuth, "2026-07-26T02:59:00Z") + // 未启用探测的 API-key 账号不入选。 + var disabledID int64 + err = scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ('probe-grok-disabled', 'grok', $1, 'active', '{}'::jsonb) + RETURNING id + `, []any{service.AccountTypeAPIKey}, &disabledID) + require.NoError(t, err) + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 3) + require.Equal(t, openaiDue, accounts[0].ID) + require.Equal(t, anthropicDue, accounts[1].ID) + require.Equal(t, grokDue, accounts[2].ID) +} diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go index aa1fcb4303..1137250636 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go @@ -28,7 +28,8 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") require.Contains(t, normalized, "status = 'active'") - require.Contains(t, normalized, "platform = 'openai'") + // 探测资格已放宽到全部 API-key 平台:候选 SQL 不得再按 platform 过滤。 + require.NotContains(t, normalized, "platform") require.Contains(t, normalized, "type = 'apikey'") require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`) require.Contains(t, normalized, "jsonb_path_query_first_tz") diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go index 497059ba5a..46d78ac5b7 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go @@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, tt.databaseSnapshot, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). + AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil)) account := &service.Account{ ID: 27, @@ -91,7 +91,7 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) Credentials: map[string]any{"api_key": "sk-test"}, Extra: tt.inputExtra, } - got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil) + got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) require.NoError(t, err) if tt.wantSnapshot == nil { require.NotContains(t, got, service.UpstreamBillingProbeExtraKey) @@ -104,6 +104,102 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) } } +func probeBoolPtr(value bool) *bool { + return &value +} + +// The probe switch drives the rate-sync switch and never the other way round: +// syncing depends on probing, so a row where sync is on but the probe key is +// missing must lose the sync flag instead of silently gaining periodic +// outbound calls on the next unrelated edit. +func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T) { + tests := []struct { + name string + databaseEnabled any + databaseRateSync any + explicitProbeEnabled *bool + explicitRateSync *bool + wantEnabled any + wantRateSync any + }{ + { + name: "sync on with missing probe key zeroes sync and keeps probing off", + databaseEnabled: nil, + databaseRateSync: []byte(`true`), + wantEnabled: nil, + wantRateSync: false, + }, + { + name: "sync on with probe off zeroes sync", + databaseEnabled: []byte(`false`), + databaseRateSync: []byte(`true`), + wantEnabled: false, + wantRateSync: false, + }, + { + name: "both on in database stay on", + databaseEnabled: []byte(`true`), + databaseRateSync: []byte(`true`), + wantEnabled: true, + wantRateSync: true, + }, + { + name: "admin enabling both explicitly still turns probing on", + databaseEnabled: nil, + databaseRateSync: nil, + explicitProbeEnabled: probeBoolPtr(true), + explicitRateSync: probeBoolPtr(true), + wantEnabled: true, + wantRateSync: true, + }, + { + name: "explicit probe disable clears sync", + databaseEnabled: []byte(`true`), + databaseRateSync: []byte(`true`), + explicitProbeEnabled: probeBoolPtr(false), + wantEnabled: false, + wantRateSync: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). + WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). + AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil)) + + account := &service.Account{ + ID: 31, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test"}, + } + got, err := lockAndMergeAccountProbeExtra( + context.Background(), client, account, tt.explicitProbeEnabled, tt.explicitRateSync, + ) + require.NoError(t, err) + if tt.wantEnabled == nil { + require.NotContains(t, got, service.UpstreamBillingProbeEnabledExtraKey) + } else { + require.Equal(t, tt.wantEnabled, got[service.UpstreamBillingProbeEnabledExtraKey]) + } + if tt.wantRateSync == nil { + require.NotContains(t, got, service.UpstreamBillingRateSyncEnabledExtraKey) + } else { + require.Equal(t, tt.wantRateSync, got[service.UpstreamBillingRateSyncEnabledExtraKey]) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) { for _, identityUnchanged := range []bool{true, false} { t.Run(map[bool]string{true: "same identity keeps snapshot", false: "changed identity clears snapshot"}[identityUnchanged], func(t *testing.T) { @@ -115,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(identityUnchanged, identityUnchanged, true, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`))) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). + AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`))) account := &service.Account{ ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, @@ -127,7 +223,7 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) service.OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "forged"}, }, } - got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil) + got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) require.NoError(t, err) if identityUnchanged { require.Equal(t, "local-ciphertext", got[service.OllamaCloudUsageSessionExtraKey]) @@ -227,8 +323,8 @@ func TestBulkUpdateProbeEligibilityMismatchRollsBack(t *testing.T) { enabled := true mock.ExpectBegin() - mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND platform = \$3 AND type = \$4`). - WithArgs(sqlmock.AnyArg(), `{27,28}`, service.PlatformOpenAI, service.AccountTypeAPIKey). + mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND type = \$3`). + WithArgs(sqlmock.AnyArg(), `{27,28}`, service.AccountTypeAPIKey). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectRollback() @@ -265,7 +361,7 @@ func TestUpdateCredentialsAtomicallyClearsProbeForOpenAIAPIKeyIdentityChange(t * require.NoError(t, mock.ExpectationsWereMet()) } -func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testing.T) { +func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -275,13 +371,13 @@ func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testin mock.ExpectBegin() mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(true, false, true, []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). + AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil)) mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`). WithArgs(int64(27)). - WillReturnRows(updatedAccountRows(27, `{"upstream_billing_probe_enabled":false}`)) + WillReturnRows(updatedAccountRows(27, `{"upstream_billing_probe_enabled":false,"upstream_billing_rate_sync_enabled":false}`)) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).WillReturnError(errors.New("outbox failed")) mock.ExpectRollback() @@ -301,10 +397,12 @@ func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testin Schedulable: true, } - err = repo.UpdateWithUpstreamBillingProbeEnabled(context.Background(), account, false) + probeDisabled := false + err = repo.UpdateWithAccountBillingSettings(context.Background(), account, &probeDisabled, nil, nil) require.EqualError(t, err, "outbox failed") require.Equal(t, false, account.Extra[service.UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, account.Extra[service.UpstreamBillingRateSyncEnabledExtraKey]) require.NotContains(t, account.Extra, service.UpstreamBillingProbeExtraKey) require.NoError(t, mock.ExpectationsWereMet()) } diff --git a/backend/internal/repository/aliyun_captcha_verifier.go b/backend/internal/repository/aliyun_captcha_verifier.go new file mode 100644 index 0000000000..5838287e98 --- /dev/null +++ b/backend/internal/repository/aliyun_captcha_verifier.go @@ -0,0 +1,81 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + captcha "github.com/alibabacloud-go/captcha-20230305/client" + openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils" + "github.com/alibabacloud-go/tea/dara" + "github.com/alibabacloud-go/tea/tea" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const aliyunCaptchaTimeoutMillis = 10_000 + +type aliyunCaptchaVerifier struct { + protocol string // "HTTPS";测试注入 "HTTP" 指向 httptest.Server + timeoutMillis int +} + +func NewAliyunCaptchaVerifier() service.AliyunCaptchaVerifier { + return &aliyunCaptchaVerifier{ + protocol: "HTTPS", + timeoutMillis: aliyunCaptchaTimeoutMillis, + } +} + +// VerifyCaptcha 调用阿里云验证码 2.0 VerifyIntelligentCaptcha。 +// AK/SK 是可热更的后台设置,每次调用按当前凭证新建 client。 +func (v *aliyunCaptchaVerifier) VerifyCaptcha(ctx context.Context, cred service.AliyunCaptchaCredentials, captchaVerifyParam string) (*service.AliyunCaptchaVerifyResult, error) { + client, err := captcha.NewClient(&openapiutil.Config{ + AccessKeyId: dara.String(cred.AccessKeyID), + AccessKeySecret: dara.String(cred.AccessKeySecret), + Endpoint: dara.String(cred.Endpoint), + Protocol: dara.String(v.protocol), + ConnectTimeout: dara.Int(v.timeoutMillis), + ReadTimeout: dara.Int(v.timeoutMillis), + }) + if err != nil { + return nil, fmt.Errorf("create aliyun captcha client: %w", err) + } + + request := &captcha.VerifyIntelligentCaptchaRequest{ + CaptchaVerifyParam: dara.String(captchaVerifyParam), + SceneId: dara.String(cred.SceneID), + } + + response, err := client.VerifyIntelligentCaptchaWithContext(ctx, request, &dara.RuntimeOptions{}) + if err != nil { + return nil, normalizeAliyunCaptchaError(err) + } + + result := &service.AliyunCaptchaVerifyResult{} + if body := response.Body; body != nil && body.Result != nil { + result.VerifyResult = dara.BoolValue(body.Result.VerifyResult) + result.VerifyCode = dara.StringValue(body.Result.VerifyCode) + } + return result, nil +} + +// normalizeAliyunCaptchaError 把 SDK 的两种错误类型归一化为 service.AliyunCaptchaAPIError, +// 其余错误(网络/超时等)原样返回。 +func normalizeAliyunCaptchaError(err error) error { + var teaErr *tea.SDKError + if errors.As(err, &teaErr) { + return &service.AliyunCaptchaAPIError{ + Code: tea.StringValue(teaErr.Code), + Message: tea.StringValue(teaErr.Message), + } + } + var daraErr *dara.SDKError + if errors.As(err, &daraErr) { + return &service.AliyunCaptchaAPIError{ + Code: dara.StringValue(daraErr.Code), + Message: dara.StringValue(daraErr.Message), + } + } + return err +} diff --git a/backend/internal/repository/aliyun_captcha_verifier_test.go b/backend/internal/repository/aliyun_captcha_verifier_test.go new file mode 100644 index 0000000000..9469a64cdb --- /dev/null +++ b/backend/internal/repository/aliyun_captcha_verifier_test.go @@ -0,0 +1,93 @@ +package repository + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// newAliyunCaptchaTestTarget 起一个假的阿里云端点,让真实 SDK 走完整的签名/序列化链路。 +func newAliyunCaptchaTestTarget(t *testing.T, handler http.HandlerFunc) (*aliyunCaptchaVerifier, service.AliyunCaptchaCredentials) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000} + cred := service.AliyunCaptchaCredentials{ + AccessKeyID: "test-ak-id", + AccessKeySecret: "test-ak-secret", + SceneID: "scene-1", + Endpoint: strings.TrimPrefix(server.URL, "http://"), + } + return verifier, cred +} + +func TestAliyunCaptchaVerifier_VerifySuccess(t *testing.T) { + var capturedParam, capturedSceneID string + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + capturedParam = r.Form.Get("CaptchaVerifyParam") + capturedSceneID = r.Form.Get("SceneId") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Code":"Success","Message":"success","RequestId":"req-1","Success":true,"Result":{"VerifyResult":true,"VerifyCode":"T001"}}`)) + }) + + result, err := verifier.VerifyCaptcha(context.Background(), cred, "the-verify-param") + require.NoError(t, err) + require.True(t, result.VerifyResult) + require.Equal(t, "T001", result.VerifyCode) + require.Equal(t, "the-verify-param", capturedParam) + require.Equal(t, "scene-1", capturedSceneID) +} + +func TestAliyunCaptchaVerifier_VerifyResultFalse(t *testing.T) { + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Code":"Success","RequestId":"req-2","Success":true,"Result":{"VerifyResult":false,"VerifyCode":"F002"}}`)) + }) + + result, err := verifier.VerifyCaptcha(context.Background(), cred, "bad-param") + require.NoError(t, err) + require.False(t, result.VerifyResult) + require.Equal(t, "F002", result.VerifyCode) +} + +func TestAliyunCaptchaVerifier_APIErrorNormalized(t *testing.T) { + verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"Code":"SignatureDoesNotMatch","Message":"Specified signature is not matched with our calculation.","RequestId":"req-3"}`)) + }) + + _, err := verifier.VerifyCaptcha(context.Background(), cred, "param") + require.Error(t, err) + var apiErr *service.AliyunCaptchaAPIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "SignatureDoesNotMatch", apiErr.Code) +} + +func TestAliyunCaptchaVerifier_TransportError(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + endpoint := strings.TrimPrefix(server.URL, "http://") + server.Close() // 立即关闭,制造连接失败 + + verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000} + cred := service.AliyunCaptchaCredentials{ + AccessKeyID: "test-ak-id", + AccessKeySecret: "test-ak-secret", + SceneID: "scene-1", + Endpoint: endpoint, + } + + _, err := verifier.VerifyCaptcha(context.Background(), cred, "param") + require.Error(t, err) + var apiErr *service.AliyunCaptchaAPIError + require.False(t, errors.As(err, &apiErr), "transport errors must not be normalized to API errors") +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 95d6012bcb..a1c7cad801 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -190,7 +190,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, + group.FieldVideoModelPrices, group.FieldWebSearchPricePerCall, + group.FieldSearchPricePer1k, + group.FieldAudioRealtimePricePerMin, + group.FieldAudioTtsPricePerMillionChars, + group.FieldAudioSttPricePerHour, group.FieldClaudeCodeOnly, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, @@ -210,6 +215,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldPeakStart, group.FieldPeakEnd, group.FieldPeakRateMultiplier, + // 分组利润控制:认证快照是调度门 enable 判定的直接来源, + // 漏选会让门静默失效;新增快照分组字段时必须同步本投影, + // 集成测试对账兜底。 + group.FieldProfitControlEnabled, + group.FieldProfitMinMargin, + group.FieldProfitSafetyBuffer, ) }). Only(ctx) @@ -963,7 +974,12 @@ func groupEntityToService(g *dbent.Group) *service.Group { VideoPrice480P: g.VideoPrice480p, VideoPrice720P: g.VideoPrice720p, VideoPrice1080P: g.VideoPrice1080p, + VideoModelPrices: service.NormalizeVideoModelPrices(g.VideoModelPrices), WebSearchPricePerCall: g.WebSearchPricePerCall, + SearchPricePer1k: g.SearchPricePer1k, + AudioRealtimePricePerMin: g.AudioRealtimePricePerMin, + AudioTTSPricePerMillionChars: g.AudioTtsPricePerMillionChars, + AudioSTTPricePerHour: g.AudioSttPricePerHour, DefaultValidityDays: g.DefaultValidityDays, ClaudeCodeOnly: g.ClaudeCodeOnly, FallbackGroupID: g.FallbackGroupID, @@ -987,6 +1003,9 @@ func groupEntityToService(g *dbent.Group) *service.Group { PeakStart: g.PeakStart, PeakEnd: g.PeakEnd, PeakRateMultiplier: g.PeakRateMultiplier, + ProfitControlEnabled: g.ProfitControlEnabled, + ProfitMinMargin: g.ProfitMinMargin, + ProfitSafetyBuffer: g.ProfitSafetyBuffer, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go index 4a462ab154..0e079d30d4 100644 --- a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go +++ b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go @@ -19,6 +19,9 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T) RateMultiplier: 1, AllowMessagesDispatch: true, DefaultMappedModel: "gpt-5.4", + VideoModelPrices: map[string]map[string]float64{ + service.VideoPriceFamilyGrokImagineVideo15: {service.VideoBillingResolution720P: 0.14}, + }, MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{ OpusMappedModel: "gpt-5.4-nano", SonnetMappedModel: "gpt-5.3-codex", @@ -32,6 +35,7 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T) got := groupEntityToService(group) require.NotNil(t, got) require.Equal(t, group.MessagesDispatchModelConfig, got.MessagesDispatchModelConfig) + require.Equal(t, group.VideoModelPrices, got.VideoModelPrices) } func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_SQLite(t *testing.T) { diff --git a/backend/internal/repository/api_key_repo_profit_projection_integration_test.go b/backend/internal/repository/api_key_repo_profit_projection_integration_test.go new file mode 100644 index 0000000000..283b103d9b --- /dev/null +++ b/backend/internal/repository/api_key_repo_profit_projection_integration_test.go @@ -0,0 +1,60 @@ +//go:build integration + +package repository + +// 投影漏列回归(repository 半程):真实 PostgreSQL 上认证专用 +// 查询 GetByKeyForAuth 的分组显式投影必须携带利润控制与计费字段。该查询是 +// 认证快照(进而是利润门 enable 判定)的唯一数据来源,漏选任何快照分组字段 +// 都会让对应功能在真实流量上静默失效。新增快照分组字段时必须同步扩展 +// GetByKeyForAuth 的 WithGroup Select 并在本测试补断言。 + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesProfitControlProjection(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("profit-proj-group-%d", suffix), + Platform: service.PlatformOpenAI, + RateMultiplier: 0.06, + ProfitControlEnabled: true, + ProfitMinMargin: 0.2, + ProfitSafetyBuffer: 0.05, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("profit-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-profit-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "profit-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group, "认证查询必须带出分组") + + require.Equal(t, service.PlatformOpenAI, got.Group.Platform) + require.InDelta(t, 0.06, got.Group.RateMultiplier, 1e-9) + require.True(t, got.Group.ProfitControlEnabled, "profit_control_enabled 必须进入认证投影(投影漏列会让门静默失效)") + require.InDelta(t, 0.2, got.Group.ProfitMinMargin, 1e-9) + require.InDelta(t, 0.05, got.Group.ProfitSafetyBuffer, 1e-9) +} diff --git a/backend/internal/repository/auth_cache_invalidation_profit_integration_test.go b/backend/internal/repository/auth_cache_invalidation_profit_integration_test.go new file mode 100644 index 0000000000..bf40042a33 --- /dev/null +++ b/backend/internal/repository/auth_cache_invalidation_profit_integration_test.go @@ -0,0 +1,98 @@ +//go:build integration + +package repository + +// migration 193 回归:groups 触发器的 durable 失效监视清单必须覆盖利润控制 +// 配置及 D 依赖的分组计价字段(正常后台保存走 InvalidateAuthCacheByGroupID 即时失效,触发器兜底 +// 直改 SQL / 更新与失效之间崩溃等 out-of-band 场景)。 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestAuthCacheInvalidationTrigger_ProfitControlColumns(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("profit-trigger-group-%d", suffix), Platform: service.PlatformOpenAI, RateMultiplier: 1, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("profit-trigger-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-profit-trigger-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "profit-trigger", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + + sum := sha256.Sum256([]byte(keyValue)) + cacheKey := hex.EncodeToString(sum[:]) + clear := func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = $1", cacheKey) + require.NoError(t, err) + } + count := func() int { + var value int + require.NoError(t, integrationDB.QueryRowContext(ctx, + "SELECT COUNT(*) FROM auth_cache_invalidation_outbox WHERE cache_key = $1", cacheKey).Scan(&value)) + return value + } + clear() + t.Cleanup(clear) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + _, err := integrationDB.ExecContext(ctx, "UPDATE groups SET name = name || '-cosmetic' WHERE id = $1", group.ID) + require.NoError(t, err) + require.Zero(t, count(), "cosmetic 更新不得入队(既有语义回归)") + + _, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_control_enabled = NOT profit_control_enabled WHERE id = $1", group.ID) + require.NoError(t, err) + require.Equal(t, 1, count(), "profit_control_enabled 变更必须入队") + clear() + + _, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_min_margin = 0.3 WHERE id = $1", group.ID) + require.NoError(t, err) + require.Equal(t, 1, count(), "profit_min_margin 变更必须入队") + clear() + + _, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_safety_buffer = 0.02 WHERE id = $1", group.ID) + require.NoError(t, err) + require.Equal(t, 1, count(), "profit_safety_buffer 变更必须入队") + clear() + + _, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_min_margin = profit_min_margin WHERE id = $1", group.ID) + require.NoError(t, err) + require.Zero(t, count(), "利润字段无实际变化的 UPDATE 不得入队") + + for name, update := range map[string]string{ + "platform": "platform = 'anthropic'", + "subscription_type": "subscription_type = 'subscription'", + "rate_multiplier": "rate_multiplier = 0.9", + "peak_rate_enabled": "peak_rate_enabled = true", + "peak_start": "peak_start = '08:00'", + "peak_end": "peak_end = '09:00'", + "peak_rate_multiplier": "peak_rate_multiplier = 1.2", + } { + t.Run(name, func(t *testing.T) { + clear() + _, err := integrationDB.ExecContext(ctx, "UPDATE groups SET "+update+" WHERE id = $1", group.ID) + require.NoError(t, err) + require.Equal(t, 1, count(), name+" 变更必须入队") + }) + } +} diff --git a/backend/internal/repository/backup_s3_store.go b/backend/internal/repository/backup_s3_store.go index a1a2feba9c..27d148adf5 100644 --- a/backend/internal/repository/backup_s3_store.go +++ b/backend/internal/repository/backup_s3_store.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "os" "path" "time" @@ -59,6 +60,34 @@ func (s *S3BackupStore) Upload(ctx context.Context, key string, body io.Reader, return int64(len(data)), nil } +func (s *S3BackupStore) UploadFile(ctx context.Context, key string, filePath string, contentType string) (int64, error) { + file, err := os.Open(filePath) + if err != nil { + return 0, fmt.Errorf("open upload file: %w", err) + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return 0, fmt.Errorf("stat upload file: %w", err) + } + sizeBytes := info.Size() + + finish := servertiming.ObserveDependency(ctx, "s3") + _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + Body: file, + ContentLength: &sizeBytes, + ContentType: &contentType, + }) + finish() + if err != nil { + return 0, fmt.Errorf("S3 PutObject file: %w", err) + } + return sizeBytes, nil +} + func (s *S3BackupStore) Download(ctx context.Context, key string) (io.ReadCloser, error) { finish := servertiming.ObserveDependency(ctx, "s3") result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ diff --git a/backend/internal/repository/backup_s3_store_test.go b/backend/internal/repository/backup_s3_store_test.go new file mode 100644 index 0000000000..c0ea7cbdfb --- /dev/null +++ b/backend/internal/repository/backup_s3_store_test.go @@ -0,0 +1,48 @@ +//go:build unit + +package repository + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestS3BackupStore_UploadFile(t *testing.T) { + var received []byte + var receivedLength int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPut, r.Method) + receivedLength = r.ContentLength + var err error + received, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := newS3Client(context.Background(), s3ClientParams{ + Endpoint: server.URL, + Region: "auto", + AccessKeyID: "test-ak", + SecretAccessKey: "test-sk", + ForcePathStyle: true, + }) + require.NoError(t, err) + + content := []byte("streamed backup payload") + filePath := t.TempDir() + "/part.gz" + require.NoError(t, os.WriteFile(filePath, content, 0o600)) + + store := &S3BackupStore{client: client, bucket: "backup-bucket"} + size, err := store.UploadFile(context.Background(), "backup/part-1", filePath, "application/octet-stream") + require.NoError(t, err) + require.Equal(t, int64(len(content)), size) + require.Equal(t, int64(len(content)), receivedLength) + require.Equal(t, content, received) +} diff --git a/backend/internal/repository/channel_monitor_v2_aggregation.go b/backend/internal/repository/channel_monitor_v2_aggregation.go new file mode 100644 index 0000000000..6b91a8132b --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_aggregation.go @@ -0,0 +1,454 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// Platform is derived from group/account (usage_logs has no provider column on upstream schema). +const channelMonitorV2PlatformSQL = `lower(` + usageLogEffectivePlatformExpr + `)` +const channelMonitorV2ModelSQL = `COALESCE(NULLIF(TRIM(ul.requested_model), ''), NULLIF(TRIM(ul.model), ''), 'unknown')` + +// Tiered retention balances UI windows against storage: +// +// 1m facts → short (late writes + rebuild rollups) +// 5m/1h/12h/1d rollups → longer, aligned to 90m / 24h / 7d / 30d(+audit) +// +// Backfill may still write short-lived 1m rows for old windows so rollups can be +// built; prune at end of each recompute drops them past their TTL while rollups remain. +const ( + channelMonitorV2RetentionUser1m = 3 * 24 * time.Hour + channelMonitorV2RetentionMetrics1m = 7 * 24 * time.Hour + channelMonitorV2RetentionError1m = 7 * 24 * time.Hour + channelMonitorV2RetentionHistogram1m = 7 * 24 * time.Hour + channelMonitorV2RetentionRollup5m = 7 * 24 * time.Hour // bucket_seconds=300 + channelMonitorV2RetentionRollup1h = 30 * 24 * time.Hour // 3600 + channelMonitorV2RetentionRollup12h = 45 * 24 * time.Hour // 43200 + channelMonitorV2RetentionRollup1d = 90 * 24 * time.Hour // 86400 + channelMonitorV2RetentionMax = channelMonitorV2RetentionRollup1d +) + +// channelMonitorV2MaxRetention is the longest stored window (1d rollup). Used to +// clamp recompute/backfill so we never scan older than product history needs. +func channelMonitorV2MaxRetention() time.Duration { + return channelMonitorV2RetentionMax +} + +func channelMonitorV2RetentionCutoff(now time.Time, retention time.Duration) time.Time { + return now.UTC().Truncate(time.Minute).Add(-retention) +} + +type channelMonitorV2RetentionRule struct { + table string + retention time.Duration + bucketSeconds int // 0 = fact table (no bucket_seconds column) +} + +// channelMonitorV2RetentionRules is ordered coarse→fine for predictable prune plans. +var channelMonitorV2RetentionRules = []channelMonitorV2RetentionRule{ + {table: "channel_monitor_v2_user_metrics_1m", retention: channelMonitorV2RetentionUser1m}, + {table: "channel_monitor_v2_metrics_1m", retention: channelMonitorV2RetentionMetrics1m}, + {table: "channel_monitor_v2_error_metrics_1m", retention: channelMonitorV2RetentionError1m}, + {table: "channel_monitor_v2_latency_histograms_1m", retention: channelMonitorV2RetentionHistogram1m}, + {table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300}, + {table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300}, + {table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300}, + {table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300}, + {table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600}, + {table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600}, + {table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600}, + {table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600}, + {table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200}, + {table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200}, + {table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200}, + {table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200}, + {table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400}, + {table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400}, + {table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400}, + {table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400}, +} + +func (r *channelMonitorV2Repository) pruneChannelMonitorV2Retention(ctx context.Context, tx *sql.Tx, now time.Time) error { + // During historical bootstrap, retain all 1m facts until the cursor reaches + // the oldest rollup boundary. Otherwise adjacent chunks would rebuild the + // same daily bucket from source rows already pruned by the prior chunk. + var backfillCursor time.Time + if err := tx.QueryRowContext(ctx, `SELECT backfill_cursor FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&backfillCursor); err == nil && backfillCursor.After(channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMax)) { + return nil + } + for _, rule := range channelMonitorV2RetentionRules { + cutoff := channelMonitorV2RetentionCutoff(now, rule.retention) + var err error + if rule.bucketSeconds == 0 { + _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE bucket_start < $1`, rule.table), cutoff) + } else { + _, err = tx.ExecContext(ctx, + fmt.Sprintf(`DELETE FROM %s WHERE bucket_seconds = $1 AND bucket_start < $2`, rule.table), + rule.bucketSeconds, cutoff, + ) + } + if err != nil { + return fmt.Errorf("prune %s (bucket_seconds=%d): %w", rule.table, rule.bucketSeconds, err) + } + } + return nil +} + +func (r *channelMonitorV2Repository) RecomputeRange(ctx context.Context, start, end time.Time) (err error) { + start = start.UTC().Truncate(time.Minute) + end = end.UTC().Truncate(time.Minute) + now := time.Now().UTC().Truncate(time.Minute) + // Clamp to longest rollup TTL so backfill does not scan beyond product history. + maxCutoff := channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention()) + if start.Before(maxCutoff) { + start = maxCutoff + } + if !start.Before(end) { + return nil + } + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + // Idempotent window rewrite: drop existing facts/rollups in [start,end) then re-insert. + for _, table := range []string{ + "channel_monitor_v2_latency_histograms_rollup", + "channel_monitor_v2_error_metrics_rollup", + "channel_monitor_v2_user_metrics_rollup", + "channel_monitor_v2_metrics_rollup", + "channel_monitor_v2_latency_histograms_1m", + "channel_monitor_v2_error_metrics_1m", + "channel_monitor_v2_user_metrics_1m", + "channel_monitor_v2_metrics_1m", + } { + if _, err = tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE bucket_start >= $1 AND bucket_start < $2", table), start, end); err != nil { + return err + } + } + + if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UsageMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil { + return fmt.Errorf("aggregate channel monitor v2 usage: %w", err) + } + if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UserMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil { + return fmt.Errorf("aggregate channel monitor v2 users: %w", err) + } + if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2HistogramSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL, channelMonitorV2HistogramBoundSQL("latency.value_ms")), start, end); err != nil { + return fmt.Errorf("aggregate channel monitor v2 histograms: %w", err) + } + if _, err = tx.ExecContext(ctx, channelMonitorV2ErrorAggregationSQL, start, end); err != nil { + return fmt.Errorf("aggregate channel monitor v2 errors: %w", err) + } + if err = r.recomputeFixedRollups(ctx, tx, start, end); err != nil { + return err + } + // Drop rows past per-tier TTL (1m short, coarse rollups long). Safe after rollup + // so a backfill chunk can build 1d rollups from temporary 1m rows then discard 1m. + if err = r.pruneChannelMonitorV2Retention(ctx, tx, now); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, channelMonitorV2WatermarkSQL, start, end); err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + return nil +} + +const channelMonitorV2UsageMetricsSQL = ` +INSERT INTO channel_monitor_v2_metrics_1m ( + bucket_start, platform, group_id, model, success_requests, + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at +) +SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, + COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text)) + FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW() +FROM usage_logs ul +LEFT JOIN groups g ON g.id = ul.group_id +LEFT JOIN accounts a ON a.id = ul.account_id +WHERE ul.created_at >= $1 AND ul.created_at < $2 +GROUP BY 1, 2, 3, 4` + +const channelMonitorV2UserMetricsSQL = ` +INSERT INTO channel_monitor_v2_user_metrics_1m ( + bucket_start, platform, group_id, model, user_id, success_requests, + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at +) +SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, ul.user_id, + COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text)) + FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0), + COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), + COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0), + COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW() +FROM usage_logs ul +LEFT JOIN groups g ON g.id = ul.group_id +LEFT JOIN accounts a ON a.id = ul.account_id +WHERE ul.created_at >= $1 AND ul.created_at < $2 AND ul.user_id IS NOT NULL +GROUP BY 1, 2, 3, 4, 5` + +const channelMonitorV2HistogramSQL = ` +INSERT INTO channel_monitor_v2_latency_histograms_1m ( + bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count +) +SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, + audience.user_id, latency.metric, %s, COUNT(*) +FROM usage_logs ul +LEFT JOIN groups g ON g.id = ul.group_id +LEFT JOIN accounts a ON a.id = ul.account_id +CROSS JOIN LATERAL (VALUES (0::bigint), (ul.user_id)) audience(user_id) +CROSS JOIN LATERAL (VALUES ('ttft'::text, ul.first_token_ms), ('duration'::text, ul.duration_ms)) latency(metric, value_ms) +WHERE ul.created_at >= $1 AND ul.created_at < $2 + AND audience.user_id IS NOT NULL AND latency.value_ms IS NOT NULL AND latency.value_ms >= 0 + AND ` + usageLogSuccessFilterUL + ` +GROUP BY 1, 2, 3, 4, 5, 6, 7` + +func channelMonitorV2HistogramBoundSQL(column string) string { + return `CASE +WHEN ` + column + ` <= 50 THEN 50 WHEN ` + column + ` <= 100 THEN 100 +WHEN ` + column + ` <= 250 THEN 250 WHEN ` + column + ` <= 500 THEN 500 +WHEN ` + column + ` <= 1000 THEN 1000 WHEN ` + column + ` <= 2000 THEN 2000 +WHEN ` + column + ` <= 3000 THEN 3000 WHEN ` + column + ` <= 5000 THEN 5000 +WHEN ` + column + ` <= 8000 THEN 8000 WHEN ` + column + ` <= 10000 THEN 10000 +WHEN ` + column + ` <= 15000 THEN 15000 WHEN ` + column + ` <= 30000 THEN 30000 +WHEN ` + column + ` <= 60000 THEN 60000 WHEN ` + column + ` <= 120000 THEN 120000 +WHEN ` + column + ` <= 300000 THEN 300000 WHEN ` + column + ` <= 600000 THEN 600000 +ELSE 2147483647 END` +} + +// Error dedup lookback: request_id branch is bounded by chunk start minus 90 +// minutes so candidate_ids never forces a full-history scan of ops_error_logs. +const channelMonitorV2ErrorAggregationSQL = ` +WITH dedup AS ( + WITH candidate_ids AS MATERIALIZED ( + SELECT DISTINCT request_id + 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 + FROM ops_error_logs current_error + WHERE ( + (NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2) + OR ( + current_error.request_id IN (SELECT request_id FROM candidate_ids) + AND current_error.created_at >= $1 - INTERVAL '90 minutes' + AND current_error.created_at < $2 + ) + ) + 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 +), classified AS ( + SELECT *, CASE + -- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles. + WHEN error_type = 'cyber_policy' OR text LIKE ANY(ARRAY['%content policy%','%content_policy%','%safety policy%','%moderation%','%blocked keyword%']) THEN 'content_policy' + WHEN status_code = 401 OR upstream_status_code = 401 OR text LIKE ANY(ARRAY['%unauthorized%','%invalid api key%','%invalid_api_key%','%authentication%','%api_key_disabled%']) THEN 'authentication' + WHEN text LIKE ANY(ARRAY['%context window%','%context length%','%maximum prompt length%','%too many tokens%','%max_tokens%']) THEN 'context_limit' + WHEN text LIKE ANY(ARRAY['%failed to deserialize%','%missing required parameter%','%invalid request%','%invalid_request%','%tool_choice%']) THEN 'invalid_request' + WHEN text LIKE ANY(ARRAY['%does not support the requested model%','%not supported by any configured account%','%model not supported%','%unsupported model%']) THEN 'model_unsupported' + WHEN text LIKE ANY(ARRAY['%group not allowed%','%group_not_allowed%','%group access%']) THEN 'group_access' + WHEN text LIKE ANY(ARRAY['%run out of credits%','%insufficient balance%','%insufficient quota%','%subscription%','%quota exceeded%','%billing hard limit%']) THEN 'quota_or_balance' + WHEN text LIKE ANY(ARRAY['%no available accounts%','%no healthy account%','%no healthy upstream account%','%failover budget exhausted%','%account pool%']) THEN 'account_pool_unavailable' + WHEN status_code = 429 OR upstream_status_code = 429 OR text LIKE ANY(ARRAY['%rate limit%','%rate_limit%','%high demand%','%overloaded%','%concurrency limit%','%capacity%']) THEN 'rate_or_capacity' + WHEN status_code IN (408,504) OR text LIKE ANY(ARRAY['%timeout%','%deadline exceeded%','%error code: 524%','%gateway time-out%','%gateway timeout%']) THEN 'timeout' + WHEN text LIKE ANY(ARRAY['%transport%','%stream_read_error%','%connection reset%','%connection refused%','%tls%','%http2%','%missing terminal event%','%unexpected eof%']) THEN 'transport_or_stream' + WHEN status_code = 403 OR upstream_status_code = 403 THEN 'upstream_forbidden' + WHEN status_code = 404 OR upstream_status_code = 404 THEN 'not_found' + WHEN status_code = 499 OR text LIKE ANY(ARRAY['%client cancelled%','%client canceled%','%context canceled%']) THEN 'client_cancelled' + WHEN upstream_status_code >= 500 OR (error_owner = 'provider' AND status_code >= 500) THEN 'upstream_5xx' + WHEN status_code >= 500 OR error_type = 'internal' OR error_owner = 'system' THEN 'internal' + ELSE 'other' END AS category + FROM dedup + WHERE bucket_start >= $1 AND bucket_start < $2 +), metric_rows AS ( + INSERT INTO channel_monitor_v2_metrics_1m (bucket_start, platform, group_id, model, error_requests, upstream_affected_requests, upstream_attempt_count, computed_at) + SELECT bucket_start, platform, group_id, model, COUNT(*), COUNT(*) FILTER (WHERE upstream_affected), SUM(upstream_attempts), NOW() + FROM classified GROUP BY 1,2,3,4 + ON CONFLICT (bucket_start, platform, group_id, model) DO UPDATE SET + error_requests = EXCLUDED.error_requests, upstream_affected_requests = EXCLUDED.upstream_affected_requests, + upstream_attempt_count = EXCLUDED.upstream_attempt_count, computed_at = NOW() +), user_rows AS ( + INSERT INTO channel_monitor_v2_user_metrics_1m (bucket_start, platform, group_id, model, user_id, error_requests, computed_at) + SELECT bucket_start, platform, group_id, model, user_id, COUNT(*), NOW() + FROM classified WHERE user_id IS NOT NULL GROUP BY 1,2,3,4,5 + ON CONFLICT (bucket_start, platform, group_id, model, user_id) DO UPDATE SET error_requests = EXCLUDED.error_requests, computed_at = NOW() +) +INSERT INTO channel_monitor_v2_error_metrics_1m (bucket_start, platform, group_id, model, error_category, taxonomy_version, error_requests) +SELECT bucket_start, platform, group_id, model, category, 1, COUNT(*) FROM classified GROUP BY 1,2,3,4,5 +ON CONFLICT (bucket_start, platform, group_id, model, error_category, taxonomy_version) +DO UPDATE SET error_requests = EXCLUDED.error_requests` + +// Floor matches channelMonitorV2RetentionMax (90d). Keep the INTERVAL literal in +// sync when changing channelMonitorV2RetentionRollup1d. +// +// Coverage starts track how far back recompute has walked ($1 = chunk start), not +// "min(source_log.created_at)". Using global min(ops_error_logs) pins +// error_coverage_start to the first real error forever and collapses UI windows +// when errors only exist in a recent slice (common on first upgrade). +const channelMonitorV2WatermarkSQL = ` +INSERT INTO channel_monitor_v2_watermarks (id, usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor, updated_at) +VALUES ( + 1, + $1, + $1, + $2, NOW(), $1, NOW() +) +ON CONFLICT (id) DO UPDATE SET + usage_coverage_start = GREATEST( + date_trunc('minute', NOW()) - INTERVAL '90 days', + LEAST(COALESCE(channel_monitor_v2_watermarks.usage_coverage_start, EXCLUDED.usage_coverage_start), EXCLUDED.usage_coverage_start) + ), + error_coverage_start = GREATEST( + date_trunc('minute', NOW()) - INTERVAL '90 days', + LEAST(COALESCE(channel_monitor_v2_watermarks.error_coverage_start, EXCLUDED.error_coverage_start), EXCLUDED.error_coverage_start) + ), + data_through = GREATEST(COALESCE(channel_monitor_v2_watermarks.data_through, EXCLUDED.data_through), EXCLUDED.data_through), + last_successful_at = NOW(), + backfill_cursor = LEAST(COALESCE(channel_monitor_v2_watermarks.backfill_cursor, EXCLUDED.backfill_cursor), EXCLUDED.backfill_cursor), + updated_at = NOW()` + +var channelMonitorV2FixedRollupSeconds = []int{300, 3600, 43200, 86400} + +func (r *channelMonitorV2Repository) recomputeFixedRollups(ctx context.Context, tx *sql.Tx, start, end time.Time) error { + for _, seconds := range channelMonitorV2FixedRollupSeconds { + // Coarse buckets are immutable between boundaries during the normal + // trailing refresh. Historical backfills and boundary-crossing windows + // still rebuild them; this avoids repeatedly regrouping the full current + // day/user table every few minutes. + if seconds >= 43200 && sameFixedRollupBucket(start, end, seconds) { + continue + } + interval := fmt.Sprintf("%d seconds", seconds) + for _, table := range []string{ + "channel_monitor_v2_latency_histograms_rollup", + "channel_monitor_v2_error_metrics_rollup", + "channel_monitor_v2_user_metrics_rollup", + "channel_monitor_v2_metrics_rollup", + } { + if _, err := tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2FixedRollupDeleteSQL, table), interval, seconds, start, end); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, channelMonitorV2MetricsRollupSQL, interval, seconds, start, end); err != nil { + return fmt.Errorf("roll up channel monitor v2 metrics %ds: %w", seconds, err) + } + if _, err := tx.ExecContext(ctx, channelMonitorV2UserMetricsRollupSQL, interval, seconds, start, end); err != nil { + return fmt.Errorf("roll up channel monitor v2 user metrics %ds: %w", seconds, err) + } + if _, err := tx.ExecContext(ctx, channelMonitorV2HistogramRollupSQL, interval, seconds, start, end); err != nil { + return fmt.Errorf("roll up channel monitor v2 histograms %ds: %w", seconds, err) + } + if _, err := tx.ExecContext(ctx, channelMonitorV2ErrorRollupSQL, interval, seconds, start, end); err != nil { + return fmt.Errorf("roll up channel monitor v2 errors %ds: %w", seconds, err) + } + } + return nil +} + +func sameFixedRollupBucket(start, end time.Time, seconds int) bool { + if !end.After(start) { + return true + } + interval := time.Duration(seconds) * time.Second + return start.Truncate(interval).Equal(end.Add(-time.Nanosecond).Truncate(interval)) +} + +const channelMonitorV2FixedRollupBoundsSQL = ` +WITH bounds AS ( + SELECT + date_bin($1::interval, $3::timestamptz, TIMESTAMPTZ '1970-01-01') AS start_at, + date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', TIMESTAMPTZ '1970-01-01') + $1::interval AS end_at +)` + +const channelMonitorV2FixedRollupDeleteSQL = channelMonitorV2FixedRollupBoundsSQL + ` +DELETE FROM %s +USING bounds +WHERE bucket_seconds = $2::integer + AND bucket_start >= bounds.start_at + AND bucket_start < bounds.end_at` + +const channelMonitorV2MetricsRollupSQL = ` +INSERT INTO channel_monitor_v2_metrics_rollup ( + bucket_start, bucket_seconds, platform, group_id, model, success_requests, error_requests, + upstream_affected_requests, upstream_attempt_count, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens, ttft_sum_ms, ttft_count, duration_sum_ms, + duration_count, computed_at +) +` + channelMonitorV2FixedRollupBoundsSQL + ` +SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer, + platform, group_id, model, SUM(success_requests), SUM(error_requests), + SUM(upstream_affected_requests), SUM(upstream_attempt_count), SUM(input_tokens), + SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens), + SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW() +FROM channel_monitor_v2_metrics_1m m, bounds +WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at +GROUP BY 1, 2, 3, 4, 5` + +const channelMonitorV2UserMetricsRollupSQL = ` +INSERT INTO channel_monitor_v2_user_metrics_rollup ( + bucket_start, bucket_seconds, platform, group_id, model, user_id, success_requests, + error_requests, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at +) +` + channelMonitorV2FixedRollupBoundsSQL + ` +SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer, + platform, group_id, model, user_id, SUM(success_requests), SUM(error_requests), + SUM(input_tokens), SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens), + SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW() +FROM channel_monitor_v2_user_metrics_1m m, bounds +WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at +GROUP BY 1, 2, 3, 4, 5, 6` + +const channelMonitorV2HistogramRollupSQL = ` +INSERT INTO channel_monitor_v2_latency_histograms_rollup ( + bucket_start, bucket_seconds, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count +) +` + channelMonitorV2FixedRollupBoundsSQL + ` +SELECT date_bin($1::interval, h.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer, + platform, group_id, model, user_id, metric, upper_bound_ms, SUM(sample_count) +FROM channel_monitor_v2_latency_histograms_1m h, bounds +WHERE h.bucket_start >= bounds.start_at AND h.bucket_start < bounds.end_at +GROUP BY 1, 2, 3, 4, 5, 6, 7, 8` + +const channelMonitorV2ErrorRollupSQL = ` +INSERT INTO channel_monitor_v2_error_metrics_rollup ( + bucket_start, bucket_seconds, platform, group_id, model, error_category, taxonomy_version, error_requests +) +` + channelMonitorV2FixedRollupBoundsSQL + ` +SELECT date_bin($1::interval, e.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer, + platform, group_id, model, error_category, taxonomy_version, SUM(error_requests) +FROM channel_monitor_v2_error_metrics_1m e, bounds +WHERE e.bucket_start >= bounds.start_at AND e.bucket_start < bounds.end_at +GROUP BY 1, 2, 3, 4, 5, 6, 7` diff --git a/backend/internal/repository/channel_monitor_v2_repo.go b/backend/internal/repository/channel_monitor_v2_repo.go new file mode 100644 index 0000000000..c9ad5031f1 --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_repo.go @@ -0,0 +1,1553 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +type channelMonitorV2Repository struct{ db *sql.DB } + +func NewChannelMonitorV2Repository(db *sql.DB) service.ChannelMonitorV2Repository { + return &channelMonitorV2Repository{db: db} +} + +func (r *channelMonitorV2Repository) GetConfig(ctx context.Context) (*service.ChannelMonitorV2Config, error) { + var cfg service.ChannelMonitorV2Config + var platforms, thresholds []byte + err := r.db.QueryRowContext(ctx, ` + SELECT version, enabled, refresh_interval_seconds, platforms, group_ids, + COALESCE(ignored_error_categories, '{}'), + COALESCE(health_thresholds, '{}'::jsonb), + updated_at, updated_by + FROM channel_monitor_v2_config WHERE id = 1`).Scan( + &cfg.Version, &cfg.Enabled, &cfg.RefreshIntervalSeconds, &platforms, + pq.Array(&cfg.GroupIDs), pq.Array(&cfg.IgnoredErrorCategories), + &thresholds, + &cfg.UpdatedAt, &cfg.UpdatedBy, + ) + if err != nil { + return nil, fmt.Errorf("get channel monitor v2 config: %w", err) + } + if err := json.Unmarshal(platforms, &cfg.Platforms); err != nil { + return nil, fmt.Errorf("decode channel monitor v2 platforms: %w", err) + } + if cfg.IgnoredErrorCategories == nil { + cfg.IgnoredErrorCategories = []string{} + } + cfg.HealthThresholds = service.DefaultChannelMonitorV2HealthThresholds() + if len(thresholds) > 0 { + _ = json.Unmarshal(thresholds, &cfg.HealthThresholds) + } + cfg.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds) + return &cfg, nil +} + +func (r *channelMonitorV2Repository) UpdateConfig(ctx context.Context, cfg service.ChannelMonitorV2Config, expectedVersion int) (*service.ChannelMonitorV2Config, error) { + platforms, err := json.Marshal(cfg.Platforms) + if err != nil { + return nil, err + } + if cfg.IgnoredErrorCategories == nil { + cfg.IgnoredErrorCategories = []string{} + } + cfg.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds) + thresholds, err := json.Marshal(cfg.HealthThresholds) + if err != nil { + return nil, err + } + var updated service.ChannelMonitorV2Config + var raw, rawThresholds []byte + err = r.db.QueryRowContext(ctx, ` + UPDATE channel_monitor_v2_config + SET version = version + 1, enabled = $1, refresh_interval_seconds = $2, + platforms = $3, group_ids = $4, ignored_error_categories = $5, + health_thresholds = $6, updated_by = $7, updated_at = NOW() + WHERE id = 1 AND version = $8 + RETURNING version, enabled, refresh_interval_seconds, platforms, group_ids, + COALESCE(ignored_error_categories, '{}'), + COALESCE(health_thresholds, '{}'::jsonb), + updated_at, updated_by`, + cfg.Enabled, cfg.RefreshIntervalSeconds, platforms, pq.Array(cfg.GroupIDs), + pq.Array(cfg.IgnoredErrorCategories), thresholds, cfg.UpdatedBy, expectedVersion, + ).Scan(&updated.Version, &updated.Enabled, &updated.RefreshIntervalSeconds, &raw, + pq.Array(&updated.GroupIDs), pq.Array(&updated.IgnoredErrorCategories), + &rawThresholds, + &updated.UpdatedAt, &updated.UpdatedBy) + if err == sql.ErrNoRows { + return nil, service.ErrChannelMonitorV2ConfigConflict + } + if err != nil { + return nil, fmt.Errorf("update channel monitor v2 config: %w", err) + } + if err := json.Unmarshal(raw, &updated.Platforms); err != nil { + return nil, err + } + if updated.IgnoredErrorCategories == nil { + updated.IgnoredErrorCategories = []string{} + } + updated.HealthThresholds = service.DefaultChannelMonitorV2HealthThresholds() + _ = json.Unmarshal(rawThresholds, &updated.HealthThresholds) + updated.HealthThresholds = service.NormalizeChannelMonitorV2HealthThresholds(updated.HealthThresholds) + return &updated, nil +} + +type channelMonitorV2Fact struct { + BucketStart, Platform, GroupName, Model string + GroupID int64 + Success, Errors, UpstreamAffected, UpstreamAttempts int64 + Input, Output, CacheCreation, CacheRead int64 + TTFTSum, TTFTCount, DurationSum, DurationCount int64 +} + +type channelMonitorV2Histogram struct { + BucketStart, Platform, Model, Metric string + GroupID, UserID int64 + UpperBound int64 + Count int64 +} + +func (r *channelMonitorV2Repository) GetDimensions(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (*service.ChannelMonitorV2Dimensions, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + // Catalog dimensions must stay independent of the currently selected + // platform/group/model multi-select filters so pickers keep their full option set. + // Time/coverage still come from the request filter; config scope (enabled platforms, + // group allow-list, display-model collapse) still applies. + catalogFilter := channelMonitorV2CatalogFilter(channelMonitorV2CommonCoverageFilter(filter, *coverage)) + where, args, _ := channelMonitorV2WhereWithRollup(catalogFilter, cfg, "m") + query := `SELECT m.platform, COALESCE(g.name, ''), lower(COALESCE(NULLIF(TRIM(g.platform), ''), 'unknown')), m.group_id, m.model, + SUM(m.success_requests + m.error_requests) + FROM ` + channelMonitorV2MetricsTable(catalogFilter) + ` m + LEFT JOIN groups g ON g.id = NULLIF(m.group_id, 0) ` + where + ` + GROUP BY m.platform, g.name, g.platform, m.group_id, m.model` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + platformCounts := map[string]int64{} + type modelValue struct { + platform string + count int64 + } + // Model dimensions are platform-scoped; identical names can represent + // different upstream models and must not share counts. + modelCounts := map[string]modelValue{} + type groupValue struct { + name string + platform string + count int64 + } + groupCounts := map[int64]groupValue{} + for _, platform := range channelMonitorV2EnabledPlatforms(cfg) { + platformCounts[platform] += 0 + for _, p := range cfg.Platforms { + if p.Platform != platform || len(p.Models) == 0 { + continue + } + for _, model := range p.Models { + modelCounts[platform+"\x00"+model] = modelValue{platform: platform, count: 0} + } + } + } + groupInfo, err := r.loadChannelMonitorV2GroupInfo(ctx, configuredChannelMonitorV2GroupIDs(catalogFilter, cfg)) + if err != nil { + return nil, err + } + for groupID, info := range groupInfo { + groupCounts[groupID] = groupValue{name: info.name, platform: info.platform, count: 0} + } + for rows.Next() { + var platform, groupName, groupPlatform, model string + var groupID, count int64 + if err := rows.Scan(&platform, &groupName, &groupPlatform, &groupID, &model, &count); err != nil { + return nil, err + } + platformCounts[platform] += count + displayModel := channelMonitorV2DisplayModel(cfg, platform, model) + modelKey := platform + "\x00" + displayModel + currentModel := modelCounts[modelKey] + currentModel.count += count + if currentModel.platform == "" { + currentModel.platform = platform + } + modelCounts[modelKey] = currentModel + if groupID > 0 { + current := groupCounts[groupID] + current.name = groupName + current.platform = groupPlatform + current.count += count + groupCounts[groupID] = current + } + } + result := &service.ChannelMonitorV2Dimensions{ + Platforms: []service.ChannelMonitorV2Dimension{}, + Groups: []service.ChannelMonitorV2GroupDimension{}, + Models: []service.ChannelMonitorV2Dimension{}, + } + for value, count := range platformCounts { + result.Platforms = append(result.Platforms, service.ChannelMonitorV2Dimension{Value: value, Label: value, RequestCount: count}) + } + for value, meta := range modelCounts { + result.Models = append(result.Models, service.ChannelMonitorV2Dimension{Value: value, Label: channelMonitorV2ModelLabel(value), Platform: meta.platform, RequestCount: meta.count}) + } + for id, value := range groupCounts { + result.Groups = append(result.Groups, service.ChannelMonitorV2GroupDimension{ID: id, Name: value.name, Platform: value.platform, RequestCount: value.count}) + } + sort.Slice(result.Platforms, func(i, j int) bool { return result.Platforms[i].RequestCount > result.Platforms[j].RequestCount }) + sort.Slice(result.Models, func(i, j int) bool { return result.Models[i].RequestCount > result.Models[j].RequestCount }) + sort.Slice(result.Groups, func(i, j int) bool { return result.Groups[i].RequestCount > result.Groups[j].RequestCount }) + return result, rows.Err() +} + +func (r *channelMonitorV2Repository) GetSnapshot(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2Snapshot, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, true) + if err != nil { + return nil, err + } + histograms, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, true) + if err != nil { + return nil, err + } + byBucket := map[string]*metricAccumulator{} + total := newMetricAccumulator() + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + bucket := fact.BucketStart + acc := byBucket[bucket] + if acc == nil { + acc = newMetricAccumulator() + byBucket[bucket] = acc + } + acc.addFact(fact) + total.addFact(fact) + } + for _, hist := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, hist.Platform, hist.Model) { + continue + } + if acc := byBucket[hist.BucketStart]; acc != nil { + acc.addHistogram(hist) + } + total.addHistogram(hist) + } + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + // Category-level ignored errors adjust error_rate without dropping volume. + ignoredByBucket, ignoredTotal, err := r.loadIgnoredErrorCounts(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts: %w", err) + } + metrics := total.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredTotal) + if !admin { + cfg.UpdatedBy = nil + } + result := &service.ChannelMonitorV2Snapshot{Config: cfg, Coverage: *coverage, Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds), Trend: []service.ChannelMonitorV2TrendPoint{}} + keys := make([]string, 0, len(byBucket)) + for key := range byBucket { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + bucket, _ := time.Parse(time.RFC3339Nano, key) + m := byBucket[key].metric(filter.Bucket.Minutes(), admin) + applyIgnoredErrors(&m, ignoredByBucket[key]) + result.Trend = append(result.Trend, service.ChannelMonitorV2TrendPoint{BucketStart: bucket, Metrics: m, Health: service.ChannelMonitorV2HealthForWithThresholds(m, cfg.HealthThresholds)}) + } + return result, nil +} + +func (r *channelMonitorV2Repository) GetModels(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, false) + if err != nil { + return nil, err + } + hist, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, false) + if err != nil { + return nil, err + } + accs := map[string]*metricAccumulator{} + for _, platform := range channelMonitorV2EnabledPlatforms(cfg) { + if len(filter.Platforms) > 0 && !containsString(filter.Platforms, platform) { + continue + } + models := configuredChannelMonitorV2Models(cfg, platform, filter) + for _, model := range models { + if model == "" { + continue + } + key := platform + "\x00" + model + if accs[key] == nil { + accs[key] = newMetricAccumulator() + } + } + } + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + model := channelMonitorV2DisplayModel(cfg, fact.Platform, fact.Model) + key := fact.Platform + "\x00" + model + if accs[key] == nil { + accs[key] = newMetricAccumulator() + } + accs[key].addFact(fact) + } + for _, h := range hist { + if !channelMonitorV2ModelSelected(filter, cfg, h.Platform, h.Model) { + continue + } + key := h.Platform + "\x00" + channelMonitorV2DisplayModel(cfg, h.Platform, h.Model) + if accs[key] != nil { + accs[key].addHistogram(h) + } + } + // Per platform+model ignored counts for error_rate adjustment. + ignoredByPM, _, err := r.loadIgnoredErrorCountsByPlatformModel(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts by platform/model: %w", err) + } + items := make([]service.ChannelMonitorV2ModelRow, 0, len(accs)) + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + for key, acc := range accs { + parts := strings.SplitN(key, "\x00", 2) + metrics := acc.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredByPM[key]) + items = append(items, service.ChannelMonitorV2ModelRow{Platform: parts[0], Model: parts[1], Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds)}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Metrics.RequestCount > items[j].Metrics.RequestCount }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2ModelRow]{Coverage: *coverage, Items: items}, nil +} + +type channelMonitorV2MatrixKey struct { + platform, model string + groupID int64 +} + +type channelMonitorV2MatrixAccumulator struct { + groupName string + total *metricAccumulator + buckets map[string]*metricAccumulator +} + +func (r *channelMonitorV2Repository) GetMatrix(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, admin bool) (*service.ChannelMonitorV2Matrix, error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + facts, err := r.loadFacts(ctx, effectiveFilter, cfg, true) + if err != nil { + return nil, err + } + histograms, err := r.loadHistograms(ctx, effectiveFilter, cfg, 0, true) + if err != nil { + return nil, err + } + + seedGroupIDs := configuredChannelMonitorV2GroupIDs(filter, cfg) + // Empty config group list means all groups — load active groups so matrix seed + // can materialize real platform/group rows (not bare platform placeholders). + if len(seedGroupIDs) == 0 && + (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) { + allIDs, loadErr := r.listActiveGroupIDs(ctx) + if loadErr != nil { + return nil, loadErr + } + seedGroupIDs = allIDs + } + groupInfo, err := r.loadChannelMonitorV2GroupInfo(ctx, seedGroupIDs) + if err != nil { + return nil, err + } + accs := seedChannelMonitorV2MatrixAccumulators(filter, cfg, groupBy, groupInfo) + for _, fact := range facts { + if !channelMonitorV2ModelSelected(filter, cfg, fact.Platform, fact.Model) { + continue + } + // platform_group dimensions require a real group; skip group_id=0 traffic rows. + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + fact.GroupID <= 0 { + continue + } + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, fact.Platform, fact.GroupID, fact.Model) + acc := accs[key] + if acc == nil { + acc = &channelMonitorV2MatrixAccumulator{total: newMetricAccumulator(), buckets: make(map[string]*metricAccumulator)} + accs[key] = acc + } + if key.groupID > 0 { + acc.groupName = fact.GroupName + } + bucket := acc.buckets[fact.BucketStart] + if bucket == nil { + bucket = newMetricAccumulator() + acc.buckets[fact.BucketStart] = bucket + } + acc.total.addFact(fact) + bucket.addFact(fact) + } + for _, histogram := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, histogram.Platform, histogram.Model) { + continue + } + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + histogram.GroupID <= 0 { + continue + } + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, histogram.Platform, histogram.GroupID, histogram.Model) + acc := accs[key] + if acc == nil { + continue + } + acc.total.addHistogram(histogram) + if bucket := acc.buckets[histogram.BucketStart]; bucket != nil { + bucket.addHistogram(histogram) + } + } + + result := &service.ChannelMonitorV2Matrix{GroupBy: groupBy, Coverage: *coverage, Items: make([]service.ChannelMonitorV2MatrixRow, 0, len(accs))} + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + ignoredByDimBucket, ignoredByDim, err := r.loadIgnoredErrorCountsByMatrixKey(ctx, effectiveFilter, cfg, groupBy) + if err != nil { + return nil, fmt.Errorf("load ignored error counts by matrix key: %w", err) + } + for key, acc := range accs { + // platform_group views only emit rows with a real group_id. + if (groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel) && + key.groupID <= 0 { + continue + } + metrics := acc.total.metric(minutes, admin) + applyIgnoredErrors(&metrics, ignoredByDim[key]) + row := service.ChannelMonitorV2MatrixRow{Platform: key.platform, GroupName: acc.groupName, Model: key.model, Metrics: metrics, Health: service.ChannelMonitorV2HealthForWithThresholds(metrics, cfg.HealthThresholds), Buckets: []service.ChannelMonitorV2TrendPoint{}} + if key.groupID > 0 { + groupID := key.groupID + row.GroupID = &groupID + } + bucketKeys := make([]string, 0, len(acc.buckets)) + for bucket := range acc.buckets { + bucketKeys = append(bucketKeys, bucket) + } + sort.Strings(bucketKeys) + for _, bucketKey := range bucketKeys { + bucketStart, parseErr := time.Parse(time.RFC3339Nano, bucketKey) + if parseErr != nil { + continue + } + bucketMetrics := acc.buckets[bucketKey].metric(filter.Bucket.Minutes(), admin) + applyIgnoredErrors(&bucketMetrics, ignoredByDimBucket[key][bucketKey]) + row.Buckets = append(row.Buckets, service.ChannelMonitorV2TrendPoint{BucketStart: bucketStart, Metrics: bucketMetrics, Health: service.ChannelMonitorV2HealthForWithThresholds(bucketMetrics, cfg.HealthThresholds)}) + } + result.Items = append(result.Items, row) + } + sort.Slice(result.Items, func(i, j int) bool { + a, b := result.Items[i], result.Items[j] + if a.Platform != b.Platform { + return a.Platform < b.Platform + } + if a.GroupName != b.GroupName { + return a.GroupName < b.GroupName + } + if a.GroupID != nil && b.GroupID != nil && *a.GroupID != *b.GroupID { + return *a.GroupID < *b.GroupID + } + return a.Model < b.Model + }) + return result, nil +} + +func channelMonitorV2MatrixDimensionKey(groupBy service.ChannelMonitorV2GroupBy, cfg service.ChannelMonitorV2Config, platform string, groupID int64, model string) channelMonitorV2MatrixKey { + key := channelMonitorV2MatrixKey{platform: platform} + if groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.groupID = groupID + } + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.model = channelMonitorV2DisplayModel(cfg, platform, model) + } + return key +} + +func seedChannelMonitorV2MatrixAccumulators(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, groupBy service.ChannelMonitorV2GroupBy, groupInfo map[int64]channelMonitorV2GroupInfo) map[channelMonitorV2MatrixKey]*channelMonitorV2MatrixAccumulator { + accs := map[channelMonitorV2MatrixKey]*channelMonitorV2MatrixAccumulator{} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + // Platform-only groupBy seeds groupID=0. When group is part of the dimension + // (platform_group / platform_group_model), never seed a bare platform row — + // only real group IDs (from config or discovered groupInfo). + needsGroup := groupBy == service.ChannelMonitorV2GroupByPlatformGroup || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel + groupIDs := []int64{0} + if needsGroup { + groupIDs = configuredChannelMonitorV2GroupIDs(filter, cfg) + if len(groupIDs) == 0 { + // Empty config group list means "all groups": use discovered active groups. + for id := range groupInfo { + groupIDs = append(groupIDs, id) + } + sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) + } + // Still empty → seed nothing; rows come only from fact traffic. + if len(groupIDs) == 0 { + return accs + } + } + for _, platform := range platforms { + models := []string{""} + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + models = configuredChannelMonitorV2Models(cfg, platform, filter) + if len(models) == 0 { + models = []string{""} + } + } + for _, groupID := range groupIDs { + info := groupInfo[groupID] + if needsGroup { + if groupID <= 0 { + continue + } + if info.platform == "" || info.platform != platform { + continue + } + } + for _, model := range models { + key := channelMonitorV2MatrixKey{platform: platform} + if needsGroup { + key.groupID = groupID + } + if groupBy == service.ChannelMonitorV2GroupByPlatformModel || groupBy == service.ChannelMonitorV2GroupByPlatformGroupModel { + key.model = model + } + if accs[key] == nil { + accs[key] = &channelMonitorV2MatrixAccumulator{groupName: info.name, total: newMetricAccumulator(), buckets: make(map[string]*metricAccumulator)} + } + } + } + } + return accs +} + +func configuredChannelMonitorV2Models(cfg service.ChannelMonitorV2Config, platform string, filter service.ChannelMonitorV2Filter) []string { + models := []string{} + for _, p := range cfg.Platforms { + if p.Platform != platform { + continue + } + models = append(models, p.Models...) + break + } + if len(filter.Models) > 0 { + if len(models) == 0 { + models = append(models, filter.Models...) + } else { + models = intersectStrings(models, filter.Models) + } + } + return models +} + +// channelMonitorV2CatalogFilter clears multi-select dimensions so catalog endpoints +// return the full configured option set for the time window. Metrics queries must +// keep using the original filter. +func channelMonitorV2CatalogFilter(filter service.ChannelMonitorV2Filter) service.ChannelMonitorV2Filter { + catalog := filter + catalog.Platforms = nil + catalog.GroupIDs = nil + catalog.Models = nil + return catalog +} + +func configuredChannelMonitorV2GroupIDs(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) []int64 { + groups := append([]int64(nil), cfg.GroupIDs...) + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + } else { + groups = append([]int64(nil), filter.GroupIDs...) + } + } + return groups +} + +type channelMonitorV2GroupInfo struct { + name string + platform string +} + +func (r *channelMonitorV2Repository) listActiveGroupIDs(ctx context.Context) ([]int64, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id FROM groups WHERE deleted_at IS NULL AND status = 'active' ORDER BY id`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (r *channelMonitorV2Repository) loadChannelMonitorV2GroupInfo(ctx context.Context, groupIDs []int64) (map[int64]channelMonitorV2GroupInfo, error) { + out := map[int64]channelMonitorV2GroupInfo{} + if len(groupIDs) == 0 { + return out, nil + } + rows, err := r.db.QueryContext(ctx, `SELECT id, COALESCE(name, ''), lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) FROM groups WHERE id = ANY($1) AND deleted_at IS NULL AND status = 'active'`, pq.Array(groupIDs)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + var name, platform string + if err := rows.Scan(&id, &name, &platform); err != nil { + return nil, err + } + out[id] = channelMonitorV2GroupInfo{name: name, platform: platform} + } + return out, rows.Err() +} + +func (r *channelMonitorV2Repository) GetErrors(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, includeAdmin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2ErrorRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + filter = channelMonitorV2CommonCoverageFilter(filter, *coverage) + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "e") + rows, err := r.db.QueryContext(ctx, `SELECT e.platform,e.model,e.error_category,SUM(e.error_requests) FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e `+where+` AND e.taxonomy_version = `+fmt.Sprint(service.ChannelMonitorV2TaxonomyVersion)+` GROUP BY e.platform,e.model,e.error_category`, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + counts := map[string]int64{} + var total int64 + ignoredSet := service.ChannelMonitorV2IgnoredCategorySet(cfg) + for rows.Next() { + var platform, model, category string + var count int64 + if err := rows.Scan(&platform, &model, &category, &count); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + if category == "" { + category = "other" + } + counts[category] += count + total += count + } + items := make([]service.ChannelMonitorV2ErrorRow, 0, len(counts)) + // Upstream message samples are admin-only: skip the raw ops_error_logs scan + // for user-facing callers (privacy + avoids multi-million-row online scans). + var details map[string][]service.ChannelMonitorV2ErrorDetail + if includeAdmin { + loaded, detailErr := r.loadErrorDetails(ctx, filter, cfg) + if detailErr != nil { + return nil, detailErr + } + details = loaded + } + for category, count := range counts { + rate := 0.0 + if total > 0 { + rate = float64(count) / float64(total) + } + _, ignored := ignoredSet[category] + row := service.ChannelMonitorV2ErrorRow{Category: category, Count: count, Rate: rate, Ignored: ignored} + if includeAdmin { + row.Details = details[category] + } + items = append(items, row) + } + // Stable: counted categories first (by count), then ignored (grey in UI). + sort.Slice(items, func(i, j int) bool { + if items[i].Ignored != items[j].Ignored { + return !items[i].Ignored && items[j].Ignored + } + return items[i].Count > items[j].Count + }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2ErrorRow]{Coverage: *coverage, Items: items}, rows.Err() +} + +func (r *channelMonitorV2Repository) loadErrorDetails(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config) (map[string][]service.ChannelMonitorV2ErrorDetail, error) { + conditions := []string{ + "current_error.created_at >= $1", + "current_error.created_at < $2", + "NOT current_error.is_count_tokens", + "(COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')", + `(NULLIF(current_error.request_id, '') IS NULL OR NOT EXISTS ( + SELECT 1 FROM ops_error_logs newer + WHERE newer.request_id = current_error.request_id + AND NOT newer.is_count_tokens + AND (COALESCE(newer.status_code, 0) >= 400 OR newer.error_type = 'cyber_policy') + AND (newer.created_at, newer.id) > (current_error.created_at, current_error.id) + ))`, + } + args := []any{filter.Start, filter.End} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + if len(platforms) > 0 { + args = append(args, pq.Array(platforms)) + conditions = append(conditions, fmt.Sprintf("lower(COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown')) = ANY($%d)", len(args))) + } else { + conditions = append(conditions, "FALSE") + } + groups := cfg.GroupIDs + groupScopeEmpty := false + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + groupScopeEmpty = len(groups) == 0 + } else { + groups = filter.GroupIDs + } + } + if groupScopeEmpty { + conditions = append(conditions, "FALSE") + } else if len(groups) > 0 { + args = append(args, pq.Array(groups)) + conditions = append(conditions, fmt.Sprintf("COALESCE(current_error.group_id, 0) = ANY($%d)", len(args))) + } + query := `SELECT + lower(COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown')) 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, + COALESCE(current_error.error_type, '') AS error_type, + COALESCE(current_error.error_owner, '') AS error_owner, + COALESCE(current_error.error_source, '') AS error_source, + COALESCE(current_error.status_code, 0) AS status_code, + COALESCE(current_error.upstream_status_code, 0) AS upstream_status_code, + LEFT(COALESCE(NULLIF(current_error.upstream_error_message, ''), NULLIF(current_error.error_message, ''), NULLIF(current_error.upstream_error_detail, ''), NULLIF(current_error.error_body, ''), current_error.error_type, ''), 600) AS message, + COUNT(*) AS count + FROM ops_error_logs current_error + WHERE ` + strings.Join(conditions, " AND ") + ` + GROUP BY 1,2,3,4,5,6,7,8,9 + ORDER BY count DESC + LIMIT 400` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string][]service.ChannelMonitorV2ErrorDetail{} + for rows.Next() { + var platform, model, errorType, owner, source, message string + var groupID int64 + var statusCode, upstreamStatusCode int + var count int64 + if err := rows.Scan(&platform, &groupID, &model, &errorType, &owner, &source, &statusCode, &upstreamStatusCode, &message, &count); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + category := service.ClassifyChannelMonitorV2Error(service.ChannelMonitorV2ErrorInput{ + ErrorType: errorType, + ErrorOwner: owner, + ErrorSource: source, + StatusCode: statusCode, + UpstreamStatusCode: upstreamStatusCode, + Message: message, + }) + if len(out[category]) >= 5 { + continue + } + out[category] = append(out[category], service.ChannelMonitorV2ErrorDetail{ + Platform: platform, + Model: channelMonitorV2DisplayModel(cfg, platform, model), + ErrorType: errorType, + StatusCode: statusCode, + UpstreamStatusCode: upstreamStatusCode, + Message: sanitizeChannelMonitorV2ErrorDetail(message), + Count: count, + }) + } + return out, rows.Err() +} + +func sanitizeChannelMonitorV2ErrorDetail(message string) string { + message = strings.Join(strings.Fields(message), " ") + replacements := []string{"Bearer ", "bearer ", "sk-", "sk-proj-"} + for _, marker := range replacements { + if idx := strings.Index(message, marker); idx >= 0 { + end := idx + len(marker) + for end < len(message) && message[end] != ' ' && message[end] != ',' && message[end] != '"' { + end++ + } + message = message[:idx] + marker + "****" + message[end:] + } + } + if len(message) > 240 { + message = message[:240] + "…" + } + return message +} + +func (r *channelMonitorV2Repository) GetUsers(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, admin bool) (*service.ChannelMonitorV2List[service.ChannelMonitorV2UserRow], error) { + coverage, err := r.loadCoverage(ctx, filter) + if err != nil { + return nil, err + } + effectiveFilter := channelMonitorV2CommonCoverageFilter(filter, *coverage) + filter = effectiveFilter + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "m") + query := `SELECT m.user_id,COALESCE(u.email,''),COALESCE(u.username,''),m.platform,m.model, + SUM(m.success_requests),SUM(m.error_requests),SUM(m.input_tokens),SUM(m.output_tokens),SUM(m.cache_creation_tokens),SUM(m.cache_read_tokens),SUM(m.ttft_sum_ms),SUM(m.ttft_count),SUM(m.duration_sum_ms),SUM(m.duration_count) + FROM ` + channelMonitorV2UserMetricsTable(filter) + ` m LEFT JOIN users u ON u.id=m.user_id ` + where + ` GROUP BY m.user_id,u.email,u.username,m.platform,m.model` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + type userMeta struct{ email, username string } + meta := map[int64]userMeta{} + accs := map[int64]*metricAccumulator{} + for rows.Next() { + var uid int64 + var email, username string + var f channelMonitorV2Fact + if err := rows.Scan(&uid, &email, &username, &f.Platform, &f.Model, &f.Success, &f.Errors, &f.Input, &f.Output, &f.CacheCreation, &f.CacheRead, &f.TTFTSum, &f.TTFTCount, &f.DurationSum, &f.DurationCount); err != nil { + return nil, err + } + if !channelMonitorV2ModelSelected(filter, cfg, f.Platform, f.Model) { + continue + } + if accs[uid] == nil { + accs[uid] = newMetricAccumulator() + } + accs[uid].addFact(f) + meta[uid] = userMeta{email, username} + } + histograms, err := r.loadHistograms(ctx, filter, cfg, -1, false) + if err != nil { + return nil, err + } + for _, histogram := range histograms { + if !channelMonitorV2ModelSelected(filter, cfg, histogram.Platform, histogram.Model) { + continue + } + if acc := accs[histogram.UserID]; acc != nil { + acc.addHistogram(histogram) + } + } + // Error category facts are not per-user. Approximate ignored-error impact by + // applying the window-level ignored/error ratio to each user's error count so + // user-rank rates stay consistent with overview scoring. + _, ignoredTotal, err := r.loadIgnoredErrorCounts(ctx, effectiveFilter, cfg) + if err != nil { + return nil, fmt.Errorf("load ignored error counts for users: %w", err) + } + var windowErrors int64 + for _, acc := range accs { + windowErrors += acc.errors + } + ignoreRatio := 0.0 + if windowErrors > 0 && ignoredTotal > 0 { + ignoreRatio = float64(ignoredTotal) / float64(windowErrors) + if ignoreRatio > 1 { + ignoreRatio = 1 + } + } + + items := make([]service.ChannelMonitorV2UserRow, 0, len(accs)) + minutes := channelMonitorV2CoveredMinutes(filter, *coverage) + for uid, acc := range accs { + id := uid + m := meta[uid] + label := m.username + if label == "" { + label = m.email + } + metrics := acc.metric(minutes, false) + if ignoreRatio > 0 && metrics.ErrorRequests > 0 { + approxIgnored := int64(float64(metrics.ErrorRequests)*ignoreRatio + 0.5) + applyIgnoredErrors(&metrics, approxIgnored) + } + // Recompute health after rate adjustment. + // (metric() does not attach health; callers that need it recompute.) + items = append(items, service.ChannelMonitorV2UserRow{UserID: &id, Email: m.email, Username: m.username, DisplayLabel: label, CanDrilldown: admin, Metrics: metrics}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Metrics.RequestCount > items[j].Metrics.RequestCount }) + return &service.ChannelMonitorV2List[service.ChannelMonitorV2UserRow]{Coverage: *coverage, Items: items}, rows.Err() +} + +func (r *channelMonitorV2Repository) loadFacts(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, byBucket bool) ([]channelMonitorV2Fact, error) { + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "m") + bucketExpr := "MIN(m.bucket_start)" + group := "m.platform,m.group_id,g.name,m.model" + if byBucket { + if bucketSeconds > 0 { + bucketExpr = "m.bucket_start" + group = bucketExpr + "," + group + } else { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,m.bucket_start,TIMESTAMPTZ '1970-01-01')" + group = bucketExpr + "," + group + } + } + query := `SELECT ` + bucketExpr + `,m.platform,m.group_id,COALESCE(g.name,''),m.model,SUM(m.success_requests),SUM(m.error_requests),SUM(m.upstream_affected_requests),SUM(m.upstream_attempt_count),SUM(m.input_tokens),SUM(m.output_tokens),SUM(m.cache_creation_tokens),SUM(m.cache_read_tokens),SUM(m.ttft_sum_ms),SUM(m.ttft_count),SUM(m.duration_sum_ms),SUM(m.duration_count) FROM ` + channelMonitorV2MetricsTable(filter) + ` m LEFT JOIN groups g ON g.id=NULLIF(m.group_id,0) ` + where + ` GROUP BY ` + group + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + facts := []channelMonitorV2Fact{} + for rows.Next() { + var bucket time.Time + var f channelMonitorV2Fact + if err := rows.Scan(&bucket, &f.Platform, &f.GroupID, &f.GroupName, &f.Model, &f.Success, &f.Errors, &f.UpstreamAffected, &f.UpstreamAttempts, &f.Input, &f.Output, &f.CacheCreation, &f.CacheRead, &f.TTFTSum, &f.TTFTCount, &f.DurationSum, &f.DurationCount); err != nil { + return nil, err + } + f.BucketStart = bucket.UTC().Format(time.RFC3339Nano) + facts = append(facts, f) + } + return facts, rows.Err() +} + +func (r *channelMonitorV2Repository) loadHistograms(ctx context.Context, filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, userID int64, byBucket bool) ([]channelMonitorV2Histogram, error) { + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "h") + if userID < 0 { + where += " AND h.user_id > 0" + } else { + args = append(args, userID) + where += fmt.Sprintf(" AND h.user_id = $%d", len(args)) + } + bucketExpr := "MIN(h.bucket_start)" + group := "h.platform,h.group_id,h.model,h.user_id,h.metric,h.upper_bound_ms" + if byBucket { + if bucketSeconds > 0 { + bucketExpr = "h.bucket_start" + group = bucketExpr + "," + group + } else { + oldArgs := args + args = []any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))} + args = append(args, oldArgs...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,h.bucket_start,TIMESTAMPTZ '1970-01-01')" + group = bucketExpr + "," + group + } + } + rows, err := r.db.QueryContext(ctx, `SELECT `+bucketExpr+`,h.platform,h.group_id,h.model,h.user_id,h.metric,h.upper_bound_ms,SUM(h.sample_count) FROM `+channelMonitorV2HistogramTable(filter)+` h `+where+` GROUP BY `+group, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := []channelMonitorV2Histogram{} + for rows.Next() { + var bucket time.Time + var h channelMonitorV2Histogram + if err := rows.Scan(&bucket, &h.Platform, &h.GroupID, &h.Model, &h.UserID, &h.Metric, &h.UpperBound, &h.Count); err != nil { + return nil, err + } + h.BucketStart = bucket.UTC().Format(time.RFC3339Nano) + out = append(out, h) + } + return out, rows.Err() +} + +func (r *channelMonitorV2Repository) GetAggregationWatermark(ctx context.Context) (*service.ChannelMonitorV2AggregationWatermark, error) { + var usageStart, errorStart, dataThrough, computed, backfill sql.NullTime + err := r.db.QueryRowContext(ctx, ` + SELECT usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor + FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&usageStart, &errorStart, &dataThrough, &computed, &backfill) + if err == sql.ErrNoRows { + return &service.ChannelMonitorV2AggregationWatermark{}, nil + } + if err != nil { + return nil, err + } + out := &service.ChannelMonitorV2AggregationWatermark{HasData: dataThrough.Valid} + if usageStart.Valid { + out.UsageCoverageStart = usageStart.Time.UTC() + } + if errorStart.Valid { + out.ErrorCoverageStart = errorStart.Time.UTC() + } + if dataThrough.Valid { + out.DataThrough = dataThrough.Time.UTC() + } + if computed.Valid { + out.LastSuccessfulAt = computed.Time.UTC() + } + if backfill.Valid { + out.BackfillCursor = backfill.Time.UTC() + } + return out, nil +} + +func (r *channelMonitorV2Repository) loadCoverage(ctx context.Context, filter service.ChannelMonitorV2Filter) (*service.ChannelMonitorV2Coverage, error) { + wm, err := r.GetAggregationWatermark(ctx) + if err != nil { + return nil, err + } + if wm == nil { + wm = &service.ChannelMonitorV2AggregationWatermark{} + } + bootstrap := service.ChannelMonitorV2BootstrapProgress(time.Now().UTC(), wm.BackfillCursor, wm.HasData) + if !wm.HasData || wm.DataThrough.IsZero() || wm.LastSuccessfulAt.IsZero() { + return &service.ChannelMonitorV2Coverage{ + RequestedStart: filter.Start, + RequestedEnd: filter.End, + CoverageStart: filter.End, + DataThrough: filter.Start, + ComputedAt: time.Time{}, + AggregationLagSeconds: 0, + CoverageComplete: false, + BucketSeconds: int(filter.Bucket.Seconds()), + Bootstrap: bootstrap, + }, nil + } + coverageStart := filter.Start + if !wm.UsageCoverageStart.IsZero() && wm.UsageCoverageStart.After(coverageStart) { + coverageStart = wm.UsageCoverageStart + } + if !wm.ErrorCoverageStart.IsZero() && wm.ErrorCoverageStart.After(coverageStart) { + coverageStart = wm.ErrorCoverageStart + } + through := filter.End + if !wm.DataThrough.IsZero() && wm.DataThrough.Before(through) { + through = wm.DataThrough + } + computedAt := wm.LastSuccessfulAt + lag := int64(0) + if !through.IsZero() { + lag = int64(time.Since(through).Seconds()) + if lag < 0 { + lag = 0 + } + } + // Complete = history depth for this range is filled (backfill reached + // filter.Start). Do not require data_through >= filter.End: ParseFilter + // aligns End to the next whole bucket (often in the future), so a healthy + // minute-level lag would otherwise always show "partial historical coverage". + return &service.ChannelMonitorV2Coverage{ + RequestedStart: filter.Start, + RequestedEnd: filter.End, + CoverageStart: coverageStart, + DataThrough: through, + ComputedAt: computedAt, + AggregationLagSeconds: lag, + CoverageComplete: channelMonitorV2HistoryCoverageComplete(coverageStart, filter.Start), + BucketSeconds: int(filter.Bucket.Seconds()), + Bootstrap: bootstrap, + }, nil +} + +// channelMonitorV2HistoryCoverageComplete is true when aggregated history +// reaches the requested window start. Trailing freshness is reported via +// data_through / aggregation_lag_seconds, not coverage_complete. +func channelMonitorV2HistoryCoverageComplete(coverageStart, filterStart time.Time) bool { + if coverageStart.IsZero() || filterStart.IsZero() { + return false + } + return !coverageStart.After(filterStart) +} + +func channelMonitorV2FixedBucketSeconds(filter service.ChannelMonitorV2Filter) int { + seconds := int(filter.Bucket.Seconds()) + switch seconds { + case 300, 3600, 43200, 86400: + return seconds + default: + return 0 + } +} + +func channelMonitorV2MetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_metrics_rollup" + } + return "channel_monitor_v2_metrics_1m" +} + +func channelMonitorV2UserMetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_user_metrics_rollup" + } + return "channel_monitor_v2_user_metrics_1m" +} + +func channelMonitorV2ErrorMetricsTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_error_metrics_rollup" + } + return "channel_monitor_v2_error_metrics_1m" +} + +func channelMonitorV2HistogramTable(filter service.ChannelMonitorV2Filter) string { + if channelMonitorV2FixedBucketSeconds(filter) > 0 { + return "channel_monitor_v2_latency_histograms_rollup" + } + return "channel_monitor_v2_latency_histograms_1m" +} + +func channelMonitorV2WhereWithRollup(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, alias string) (string, []any, int) { + where, args := channelMonitorV2Where(filter, cfg, alias) + bucketSeconds := channelMonitorV2FixedBucketSeconds(filter) + if bucketSeconds > 0 { + args = append(args, bucketSeconds) + where += fmt.Sprintf(" AND %s.bucket_seconds = $%d", alias, len(args)) + } + return where, args, bucketSeconds +} + +func channelMonitorV2Where(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, alias string) (string, []any) { + conditions := []string{alias + ".bucket_start >= $1", alias + ".bucket_start < $2"} + args := []any{filter.Start, filter.End} + platforms := channelMonitorV2EnabledPlatforms(cfg) + if len(filter.Platforms) > 0 { + platforms = intersectStrings(platforms, filter.Platforms) + } + if len(platforms) > 0 { + args = append(args, pq.Array(platforms)) + conditions = append(conditions, fmt.Sprintf("%s.platform = ANY($%d)", alias, len(args))) + } else { + conditions = append(conditions, "FALSE") + } + groups := cfg.GroupIDs + groupScopeEmpty := false + if len(filter.GroupIDs) > 0 { + if len(groups) > 0 { + groups = intersectInt64(groups, filter.GroupIDs) + groupScopeEmpty = len(groups) == 0 + } else { + groups = filter.GroupIDs + } + } + if groupScopeEmpty { + conditions = append(conditions, "FALSE") + } else if len(groups) > 0 { + args = append(args, pq.Array(groups)) + conditions = append(conditions, fmt.Sprintf("%s.group_id = ANY($%d)", alias, len(args))) + } + return "WHERE " + strings.Join(conditions, " AND "), args +} + +func channelMonitorV2EnabledPlatforms(cfg service.ChannelMonitorV2Config) []string { + out := []string{} + for _, p := range cfg.Platforms { + if p.Enabled { + out = append(out, p.Platform) + } + } + return out +} + +// channelMonitorV2DisplayModel maps a raw model name for presentation. +// Semantics (parallel to empty group_ids = all groups): +// - platform not in config / disabled → keep raw model (still collected) +// - models list empty → show the real model name (no collapsing) +// - models list non-empty → selected keep identity; everything else → __other__ +func channelMonitorV2DisplayModel(cfg service.ChannelMonitorV2Config, platform, model string) string { + model = strings.TrimSpace(model) + if model == "" { + return service.ChannelMonitorV2OtherModel + } + for _, p := range cfg.Platforms { + if p.Platform != platform { + continue + } + // Empty allow-list: surface every real model instead of dumping into __other__. + // Operators opt into the named + __other__ split only by listing models. + if len(p.Models) == 0 { + return model + } + for _, selected := range p.Models { + if selected == model { + return model + } + } + return service.ChannelMonitorV2OtherModel + } + // Platform not configured: still show the real model so traffic is visible. + return model +} +func channelMonitorV2ModelSelected(filter service.ChannelMonitorV2Filter, cfg service.ChannelMonitorV2Config, platform, model string) bool { + if len(filter.Models) == 0 { + return true + } + display := channelMonitorV2DisplayModel(cfg, platform, model) + for _, selected := range filter.Models { + if selected == display { + return true + } + } + return false +} +func channelMonitorV2ModelLabel(model string) string { + if model == service.ChannelMonitorV2OtherModel { + return "Other models" + } + return model +} + +func channelMonitorV2CoveredMinutes(filter service.ChannelMonitorV2Filter, coverage service.ChannelMonitorV2Coverage) float64 { + start := filter.Start + if coverage.CoverageStart.After(start) { + start = coverage.CoverageStart + } + end := filter.End + if !coverage.DataThrough.IsZero() && coverage.DataThrough.Before(end) { + end = coverage.DataThrough + } + if !start.Before(end) { + return 1 + } + return end.Sub(start).Minutes() +} + +// Usage and error sources can have different retention windows. Composite +// health metrics must use their common interval; otherwise error rates mix +// unlike periods and RPM/TPM use a denominator shorter than their numerator. +func channelMonitorV2CommonCoverageFilter(filter service.ChannelMonitorV2Filter, coverage service.ChannelMonitorV2Coverage) service.ChannelMonitorV2Filter { + if coverage.CoverageStart.After(filter.Start) { + filter.Start = coverage.CoverageStart + } + if !coverage.DataThrough.IsZero() && coverage.DataThrough.Before(filter.End) { + filter.End = coverage.DataThrough + } + return filter +} +func intersectStrings(a, b []string) []string { + set := map[string]struct{}{} + for _, v := range b { + set[v] = struct{}{} + } + out := []string{} + for _, v := range a { + if _, ok := set[v]; ok { + out = append(out, v) + } + } + return out +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func intersectInt64(a, b []int64) []int64 { + set := map[int64]struct{}{} + for _, v := range b { + set[v] = struct{}{} + } + out := []int64{} + for _, v := range a { + if _, ok := set[v]; ok { + out = append(out, v) + } + } + return out +} + +// shiftSQLPlaceholders shifts existing positional placeholders by offset. +func shiftSQLPlaceholders(query string, offset int) string { + for i := 64; i >= 1; i-- { + query = strings.ReplaceAll(query, fmt.Sprintf("$%d", i), fmt.Sprintf("$%d", i+offset)) + } + return query +} + +type metricAccumulator struct { + success, errors, upstreamAffected, upstreamAttempts, input, output, cacheCreation, cacheRead, ttftSum, ttftCount, durationSum, durationCount int64 + hist map[string]map[int64]int64 +} + +func newMetricAccumulator() *metricAccumulator { + return &metricAccumulator{hist: map[string]map[int64]int64{"ttft": {}, "duration": {}}} +} +func (a *metricAccumulator) addFact(f channelMonitorV2Fact) { + a.success += f.Success + a.errors += f.Errors + a.upstreamAffected += f.UpstreamAffected + a.upstreamAttempts += f.UpstreamAttempts + a.input += f.Input + a.output += f.Output + a.cacheCreation += f.CacheCreation + a.cacheRead += f.CacheRead + a.ttftSum += f.TTFTSum + a.ttftCount += f.TTFTCount + a.durationSum += f.DurationSum + a.durationCount += f.DurationCount +} +func (a *metricAccumulator) addHistogram(h channelMonitorV2Histogram) { + if a.hist[h.Metric] == nil { + a.hist[h.Metric] = map[int64]int64{} + } + a.hist[h.Metric][h.UpperBound] += h.Count +} +func (a *metricAccumulator) metric(minutes float64, admin bool) service.ChannelMonitorV2Metric { + requests := a.success + a.errors + tokens := a.input + a.output + a.cacheCreation + a.cacheRead + denom := a.input + a.cacheCreation + a.cacheRead + if minutes <= 0 { + minutes = 1 + } + m := service.ChannelMonitorV2Metric{SuccessRequests: a.success, ErrorRequests: a.errors, RequestCount: requests, InputTokens: a.input, OutputTokens: a.output, CacheCreationTokens: a.cacheCreation, CacheReadTokens: a.cacheRead, TokenCount: tokens, RPM: float64(requests) / minutes, TPM: float64(tokens) / minutes, CacheRateNumerator: a.cacheRead, CacheRateDenominator: denom, TTFT: latencyMetric(a.ttftSum, a.ttftCount, a.hist["ttft"]), Duration: latencyMetric(a.durationSum, a.durationCount, a.hist["duration"])} + if requests > 0 { + m.ErrorRate = float64(a.errors) / float64(requests) + m.SuccessRate = float64(a.success) / float64(requests) + } + if denom > 0 { + m.CacheRate = float64(a.cacheRead) / float64(denom) + } + if admin { + affected, attempts := a.upstreamAffected, a.upstreamAttempts + m.UpstreamAffectedRequests = &affected + m.UpstreamAttemptCount = &attempts + } + return m +} +func latencyMetric(sum, count int64, hist map[int64]int64) service.ChannelMonitorV2Latency { + result := service.ChannelMonitorV2Latency{SampleCount: count} + if count > 0 { + avg := float64(sum) / float64(count) + result.AvgMs = &avg + } + result.P50Ms = histPercentile(hist, 0.50) + result.P90Ms = histPercentile(hist, 0.90) + result.P95Ms = histPercentile(hist, 0.95) + return result +} +func histPercentile(hist map[int64]int64, p float64) *int64 { + var total int64 + keys := make([]int64, 0, len(hist)) + for bound, count := range hist { + keys = append(keys, bound) + total += count + } + if total == 0 { + return nil + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + target := int64(float64(total)*p + 0.999999) + var cumulative int64 + for _, bound := range keys { + cumulative += hist[bound] + if cumulative >= target { + v := bound + return &v + } + } + v := keys[len(keys)-1] + return &v +} + +// applyIgnoredErrors rewrites ErrorRate so ignored categories do not inflate the +// scored error rate used by health. Absolute ErrorRequests / RequestCount stay +// for volume/RPM. SuccessRate remains true success/request (ignored errors are +// not treated as successes). +func applyIgnoredErrors(m *service.ChannelMonitorV2Metric, ignoredCount int64) { + if m == nil || ignoredCount <= 0 || m.RequestCount <= 0 { + return + } + if ignoredCount > m.ErrorRequests { + ignoredCount = m.ErrorRequests + } + countedErrors := m.ErrorRequests - ignoredCount + if countedErrors < 0 { + countedErrors = 0 + } + m.ErrorRate = float64(countedErrors) / float64(m.RequestCount) + // Keep SuccessRate = SuccessRequests / RequestCount when absolute success is + // known; fall back to residual only if SuccessRequests was never populated. + if m.SuccessRequests > 0 || m.ErrorRequests >= m.RequestCount { + m.SuccessRate = float64(m.SuccessRequests) / float64(m.RequestCount) + } + if m.SuccessRate < 0 { + m.SuccessRate = 0 + } + if m.SuccessRate > 1 { + m.SuccessRate = 1 + } +} + +// loadIgnoredErrorCounts returns per-bucket and total ignored error request counts +// for categories listed in cfg.IgnoredErrorCategories. Buckets use the same +// date_bin alignment as loadFacts when filter.Bucket is set. +func (r *channelMonitorV2Repository) loadIgnoredErrorCounts( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, +) (byBucket map[string]int64, total int64, err error) { + byBucket = map[string]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byBucket, 0, nil + } + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "e") + bucketExpr := "e.bucket_start" + groupBy := "e.bucket_start, e.platform, e.model" + if filter.Bucket > 0 && bucketSeconds == 0 { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')" + groupBy = bucketExpr + ", e.platform, e.model" + } + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT %s, e.platform, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY %s`, + bucketExpr, where, catIdx, taxIdx, groupBy, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byBucket, 0, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var bucket time.Time + var platform, model string + var count int64 + if err := rows.Scan(&bucket, &platform, &model, &count); err != nil { + return byBucket, 0, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + key := bucket.UTC().Format(time.RFC3339Nano) + byBucket[key] += count + total += count + } + return byBucket, total, rows.Err() +} + +// loadIgnoredErrorCountsByPlatformModel keys are platform + "\x00" + display model. +func (r *channelMonitorV2Repository) loadIgnoredErrorCountsByPlatformModel( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, +) (byPM map[string]int64, total int64, err error) { + byPM = map[string]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byPM, 0, nil + } + where, args, _ := channelMonitorV2WhereWithRollup(filter, cfg, "e") + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT e.platform, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY e.platform, e.model`, + where, catIdx, taxIdx, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byPM, 0, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var platform, model string + var count int64 + if err := rows.Scan(&platform, &model, &count); err != nil { + return byPM, 0, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + display := channelMonitorV2DisplayModel(cfg, platform, model) + byPM[platform+"\x00"+display] += count + total += count + } + return byPM, total, rows.Err() +} + +// loadIgnoredErrorCountsByMatrixKey returns ignored counts keyed by matrix dimension +// and by (dimension, bucket). Uses the same date_bin alignment as GetMatrix facts. +func (r *channelMonitorV2Repository) loadIgnoredErrorCountsByMatrixKey( + ctx context.Context, + filter service.ChannelMonitorV2Filter, + cfg service.ChannelMonitorV2Config, + groupBy service.ChannelMonitorV2GroupBy, +) (byDimBucket map[channelMonitorV2MatrixKey]map[string]int64, byDim map[channelMonitorV2MatrixKey]int64, err error) { + byDimBucket = map[channelMonitorV2MatrixKey]map[string]int64{} + byDim = map[channelMonitorV2MatrixKey]int64{} + if len(cfg.IgnoredErrorCategories) == 0 { + return byDimBucket, byDim, nil + } + where, args, bucketSeconds := channelMonitorV2WhereWithRollup(filter, cfg, "e") + bucketExpr := "e.bucket_start" + groupSQL := "e.bucket_start, e.platform, e.group_id, e.model" + if filter.Bucket > 0 && bucketSeconds == 0 { + args = append([]any{fmt.Sprintf("%d seconds", int(filter.Bucket.Seconds()))}, args...) + where = shiftSQLPlaceholders(where, 1) + bucketExpr = "date_bin($1::interval,e.bucket_start,TIMESTAMPTZ '1970-01-01')" + groupSQL = bucketExpr + ", e.platform, e.group_id, e.model" + } + args = append(args, pq.Array(cfg.IgnoredErrorCategories), service.ChannelMonitorV2TaxonomyVersion) + catIdx := len(args) - 1 + taxIdx := len(args) + query := fmt.Sprintf( + `SELECT %s, e.platform, e.group_id, e.model, SUM(e.error_requests) + FROM `+channelMonitorV2ErrorMetricsTable(filter)+` e %s + AND e.error_category = ANY($%d) AND e.taxonomy_version = $%d + GROUP BY %s`, + bucketExpr, where, catIdx, taxIdx, groupSQL, + ) + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return byDimBucket, byDim, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var bucket time.Time + var platform, model string + var groupID, count int64 + if err := rows.Scan(&bucket, &platform, &groupID, &model, &count); err != nil { + return byDimBucket, byDim, err + } + if !channelMonitorV2ModelSelected(filter, cfg, platform, model) { + continue + } + bucketKey := bucket.UTC().Format(time.RFC3339Nano) + key := channelMonitorV2MatrixDimensionKey(groupBy, cfg, platform, groupID, model) + byDim[key] += count + if byDimBucket[key] == nil { + byDimBucket[key] = map[string]int64{} + } + byDimBucket[key][bucketKey] += count + } + return byDimBucket, byDim, rows.Err() +} diff --git a/backend/internal/repository/channel_monitor_v2_repo_test.go b/backend/internal/repository/channel_monitor_v2_repo_test.go new file mode 100644 index 0000000000..14b54eb1fc --- /dev/null +++ b/backend/internal/repository/channel_monitor_v2_repo_test.go @@ -0,0 +1,288 @@ +package repository + +import ( + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) { + cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{ + {Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}}, + {Platform: "grok", Enabled: true, Models: []string{"grok-4"}}, + // Empty models list must NOT collapse everything into __other__. + {Platform: "anthropic", Enabled: true, Models: []string{}}, + }} + require.Equal(t, "shared", channelMonitorV2DisplayModel(cfg, "openai", "shared")) + require.Equal(t, service.ChannelMonitorV2OtherModel, channelMonitorV2DisplayModel(cfg, "grok", "shared")) + require.Equal(t, "claude-sonnet-4", channelMonitorV2DisplayModel(cfg, "anthropic", "claude-sonnet-4")) + // Unconfigured platform still surfaces the real model name. + require.Equal(t, "gemini-2.5-pro", channelMonitorV2DisplayModel(cfg, "gemini", "gemini-2.5-pro")) + require.True(t, channelMonitorV2ModelSelected(service.ChannelMonitorV2Filter{Models: []string{service.ChannelMonitorV2OtherModel}}, cfg, "grok", "shared")) +} + +func TestChannelMonitorV2MatrixDimensionKey(t *testing.T) { + cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}} + key := channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformGroupModel, cfg, "openai", 7, "gpt-5") + require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", groupID: 7, model: "gpt-5"}, key) + key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformModel, cfg, "openai", 7, "unlisted") + require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", model: service.ChannelMonitorV2OtherModel}, key) + key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatform, cfg, "openai", 7, "gpt-5") + require.Equal(t, channelMonitorV2MatrixKey{platform: "openai"}, key) +} + +func TestChannelMonitorV2HistogramPercentilesAreMergedFromCounts(t *testing.T) { + // 100 samples: 50@100, 40@500, 10@1000 + // target = int64(total*p + 0.999999) truncates: p50→50, p90→90, p95→95 + // cumulative hits: p50@100, p90@500 (50+40), p95@1000 + histogram := map[int64]int64{100: 50, 500: 40, 1000: 10} + require.Equal(t, int64(100), *histPercentile(histogram, .5)) + require.Equal(t, int64(500), *histPercentile(histogram, .9)) + require.Equal(t, int64(1000), *histPercentile(histogram, .95)) + require.Nil(t, histPercentile(nil, .95)) + // latencyMetric exposes avg + p50 + p90 + p95 + lat := latencyMetric(1000, 10, histogram) + require.NotNil(t, lat.AvgMs) + require.NotNil(t, lat.P50Ms) + require.NotNil(t, lat.P90Ms) + require.NotNil(t, lat.P95Ms) + require.Equal(t, int64(100), *lat.P50Ms) + require.Equal(t, int64(500), *lat.P90Ms) + require.Equal(t, int64(1000), *lat.P95Ms) +} + +func TestChannelMonitorV2MetricIncludesSuccessRate(t *testing.T) { + acc := newMetricAccumulator() + acc.success, acc.errors = 80, 20 + metric := acc.metric(1, false) + require.Equal(t, int64(100), metric.RequestCount) + require.InDelta(t, 0.8, metric.SuccessRate, 0.0001) + require.InDelta(t, 0.2, metric.ErrorRate, 0.0001) + require.Nil(t, metric.UpstreamAffectedRequests) + + adminMetric := acc.metric(1, true) + require.NotNil(t, adminMetric.UpstreamAffectedRequests) +} + +func TestChannelMonitorV2WhereUsesConfiguredScopeAndEmptyFilterMeansAllConfigured(t *testing.T) { + filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0)} + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}, {Platform: "grok", Enabled: false}}, + GroupIDs: []int64{3, 4}, + } + where, args := channelMonitorV2Where(filter, cfg, "m") + require.Contains(t, where, "m.platform = ANY($3)") + require.Contains(t, where, "m.group_id = ANY($4)") + require.Len(t, args, 4) +} + +func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testing.T) { + filter := service.ChannelMonitorV2Filter{ + Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9}, + } + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}}, + GroupIDs: []int64{3, 4}, + } + where, args := channelMonitorV2Where(filter, cfg, "m") + require.Contains(t, where, "FALSE") + require.NotContains(t, where, "m.group_id = ANY") + require.Len(t, args, 3) +} + +func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) { + query := strings.ToLower(channelMonitorV2ErrorAggregationSQL) + require.Contains(t, query, "not current_error.is_count_tokens") + require.Contains(t, query, "error_type = 'cyber_policy'") + require.Contains(t, query, "distinct on") + 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") + // 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 TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) { + for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} { + require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)") + require.Contains(t, query, "ul.actual_cost > 0") + } + require.Contains(t, channelMonitorV2PlatformSQL, "g.platform = 'composite'") + require.Contains(t, channelMonitorV2PlatformSQL, "a.platform") + require.Contains(t, channelMonitorV2HistogramSQL, "ul.actual_cost > 0") +} + +func TestChannelMonitorV2RatesUseCoveredWindow(t *testing.T) { + start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + filter := service.ChannelMonitorV2Filter{Start: start, End: start.Add(24 * time.Hour)} + coverage := service.ChannelMonitorV2Coverage{CoverageStart: start.Add(6 * time.Hour), DataThrough: start.Add(18 * time.Hour)} + require.Equal(t, 12*60.0, channelMonitorV2CoveredMinutes(filter, coverage)) + effective := channelMonitorV2CommonCoverageFilter(filter, coverage) + require.Equal(t, coverage.CoverageStart, effective.Start) + require.Equal(t, coverage.DataThrough, effective.End) +} + +func TestChannelMonitorV2HistoryCoverageCompleteIgnoresTrailingLag(t *testing.T) { + start := time.Date(2026, 8, 7, 2, 20, 0, 0, time.UTC) + // History reaches the window start → complete even if data_through is behind filter.End. + require.True(t, channelMonitorV2HistoryCoverageComplete(start, start)) + require.True(t, channelMonitorV2HistoryCoverageComplete(start.Add(-time.Hour), start)) + // Backfill still short of the window start → incomplete. + require.False(t, channelMonitorV2HistoryCoverageComplete(start.Add(time.Hour), start)) + require.False(t, channelMonitorV2HistoryCoverageComplete(time.Time{}, start)) +} + +func TestChannelMonitorV2TierRetentionPolicy(t *testing.T) { + require.Equal(t, 3*24*time.Hour, channelMonitorV2RetentionUser1m) + require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionMetrics1m) + require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionError1m) + require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionHistogram1m) + require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionRollup5m) + require.Equal(t, 30*24*time.Hour, channelMonitorV2RetentionRollup1h) + require.Equal(t, 45*24*time.Hour, channelMonitorV2RetentionRollup12h) + require.Equal(t, 90*24*time.Hour, channelMonitorV2RetentionRollup1d) + require.Equal(t, channelMonitorV2RetentionRollup1d, channelMonitorV2MaxRetention()) + require.Contains(t, channelMonitorV2WatermarkSQL, "INTERVAL '90 days'") + + // Every fixed rollup second must appear with a retention rule. + wantSeconds := map[int]time.Duration{ + 300: channelMonitorV2RetentionRollup5m, + 3600: channelMonitorV2RetentionRollup1h, + 43200: channelMonitorV2RetentionRollup12h, + 86400: channelMonitorV2RetentionRollup1d, + } + seen := map[int]time.Duration{} + for _, rule := range channelMonitorV2RetentionRules { + if rule.bucketSeconds == 0 { + require.True(t, rule.retention > 0) + continue + } + if prev, ok := seen[rule.bucketSeconds]; ok { + require.Equal(t, prev, rule.retention) + } + seen[rule.bucketSeconds] = rule.retention + } + for seconds, want := range wantSeconds { + got, ok := seen[seconds] + require.Truef(t, ok, "missing retention rule for bucket_seconds=%d", seconds) + require.Equal(t, want, got) + } + + now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + require.Equal(t, now.Add(-7*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMetrics1m)) + require.Equal(t, now.Add(-90*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention())) +} + +func TestSameFixedRollupBucket(t *testing.T) { + start := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC) + require.True(t, sameFixedRollupBucket(start, start.Add(10*time.Minute), 86400)) + require.False(t, sameFixedRollupBucket(start, start.Add(15*time.Hour), 43200)) + require.False(t, sameFixedRollupBucket(start, start.Add(24*time.Hour), 86400)) +} + +// Needles present in service.ClassifyChannelMonitorV2Error must appear in the +// aggregation SQL CASE so rollup categories match drilldown classification. +func TestChannelMonitorV2SQLTaxonomyContainsGoNeedles(t *testing.T) { + sql := channelMonitorV2ErrorAggregationSQL + needles := []string{ + "blocked keyword", + "invalid_api_key", + "max_tokens", + "invalid_request", + "model not supported", + "billing hard limit", + "no healthy upstream account", + "rate_limit", + "gateway timeout", + "connection refused", + "unexpected eof", + } + for _, needle := range needles { + require.Containsf(t, strings.ToLower(sql), strings.ToLower(needle), "SQL taxonomy missing Go needle %q", needle) + } +} + +func TestApplyIgnoredErrorsAdjustsRatesKeepsAbsoluteVolume(t *testing.T) { + m := service.ChannelMonitorV2Metric{ + RequestCount: 100, + ErrorRequests: 20, + ErrorRate: 0.20, + SuccessRate: 0.80, + } + // Success absolute still 80 → success rate stays 0.80 even after ignoring 5 errors. + m.SuccessRequests = 80 + applyIgnoredErrors(&m, 5) + require.Equal(t, int64(100), m.RequestCount) + require.Equal(t, int64(20), m.ErrorRequests) + require.InDelta(t, 0.15, m.ErrorRate, 0.0001) + require.InDelta(t, 0.80, m.SuccessRate, 0.0001) + + // Clamp ignored > errors: scored error_rate → 0; success stays true ratio. + m2 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8} + applyIgnoredErrors(&m2, 99) + require.InDelta(t, 0.0, m2.ErrorRate, 0.0001) + require.InDelta(t, 0.8, m2.SuccessRate, 0.0001) + + // No-op when ignored is zero + m3 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8} + applyIgnoredErrors(&m3, 0) + require.InDelta(t, 0.2, m3.ErrorRate, 0.0001) + require.InDelta(t, 0.8, m3.SuccessRate, 0.0001) +} + +func TestRedactChannelMonitorV2MetricZerosVolume(t *testing.T) { + // Service helper is in service package; covered there. Keep a smoke note that + // rates survive a manual zeroing of volume fields used by the UI contract. + m := service.ChannelMonitorV2Metric{ + RequestCount: 100, ErrorRequests: 10, SuccessRequests: 90, + TokenCount: 1000, RPM: 5, TPM: 50, ErrorRate: 0.1, SuccessRate: 0.9, CacheRate: 0.4, + } + // Mimic redact: zero volume only + m.RequestCount, m.ErrorRequests, m.SuccessRequests, m.TokenCount = 0, 0, 0, 0 + require.Equal(t, 0.1, m.ErrorRate) + require.Equal(t, 5.0, m.RPM) +} + +func TestChannelMonitorV2CatalogFilterClearsMultiSelectDimensions(t *testing.T) { + start := time.Unix(1, 0) + end := time.Unix(2, 0) + filter := service.ChannelMonitorV2Filter{ + Start: start, End: end, Bucket: time.Minute, + Platforms: []string{"openai"}, GroupIDs: []int64{3}, Models: []string{"gpt-5"}, + } + catalog := channelMonitorV2CatalogFilter(filter) + require.Nil(t, catalog.Platforms) + require.Nil(t, catalog.GroupIDs) + require.Nil(t, catalog.Models) + // Time window / coverage-related fields remain. + require.Equal(t, start, catalog.Start) + require.Equal(t, end, catalog.End) + require.Equal(t, time.Minute, catalog.Bucket) + + cfg := service.ChannelMonitorV2Config{ + Platforms: []service.ChannelMonitorV2PlatformConfig{ + {Platform: "openai", Enabled: true}, + {Platform: "grok", Enabled: true}, + }, + GroupIDs: []int64{3, 4}, + } + catalogWhere, catalogArgs := channelMonitorV2Where(catalog, cfg, "m") + _, metricArgs := channelMonitorV2Where(filter, cfg, "m") + + // Catalog WHERE still applies config scope (enabled platforms + group allow-list). + require.Contains(t, catalogWhere, "m.platform = ANY") + require.Contains(t, catalogWhere, "m.group_id = ANY") + require.Len(t, catalogArgs, 4) // start, end, platforms, groups + require.Len(t, metricArgs, 4) + + // Metrics WHERE is narrower once multi-select platforms/groups are applied. + require.NotEqual(t, catalogArgs, metricArgs) + // Group seeding without multi-select uses full config allow-list. + require.Equal(t, []int64{3, 4}, configuredChannelMonitorV2GroupIDs(catalog, cfg)) + require.Equal(t, []int64{3}, configuredChannelMonitorV2GroupIDs(filter, cfg)) +} diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 48c33364c3..ca927be265 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -89,7 +89,10 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetStatus(g.Status). SetSubscriptionType(g.SubscriptionType). SetRateMultiplier(g.RateMultiplier). - SetIsExclusive(g.IsExclusive) + SetIsExclusive(g.IsExclusive). + SetProfitControlEnabled(g.ProfitControlEnabled). + SetProfitMinMargin(g.ProfitMinMargin). + SetProfitSafetyBuffer(g.ProfitSafetyBuffer) if g.Description != "" { create.SetDescription(g.Description) } diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index d19c3cd512..dc3d4a980a 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -4,8 +4,10 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -31,7 +33,14 @@ func buildSessionKey(groupID int64, sessionHash string) string { func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) { key := buildSessionKey(groupID, sessionHash) - return c.rdb.Get(ctx, key).Int64() + accountID, err := c.rdb.Get(ctx, key).Int64() + if err != nil { + if errors.Is(err, redis.Nil) { + return 0, service.ErrStickySessionNotFound + } + return 0, err + } + return accountID, nil } func (c *gatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error { @@ -56,6 +65,68 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64 return c.rdb.Del(ctx, key).Err() } +const ( + grokVideoPendingBillingPrefix = "grok_video_pending:" + grokVideoBilledPrefix = "grok_video_billed:" +) + +func (c *gatewayCache) SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error { + if c == nil || c.rdb == nil { + return errors.New("gateway cache unavailable") + } + key = strings.TrimSpace(key) + if key == "" || len(payload) == 0 { + return errors.New("invalid grok video pending billing payload") + } + if ttl <= 0 { + ttl = 24 * time.Hour + } + return c.rdb.Set(ctx, grokVideoPendingBillingPrefix+key, payload, ttl).Err() +} + +func (c *gatewayCache) GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error) { + if c == nil || c.rdb == nil { + return nil, errors.New("gateway cache unavailable") + } + key = strings.TrimSpace(key) + if key == "" { + return nil, errors.New("invalid grok video pending billing key") + } + val, err := c.rdb.Get(ctx, grokVideoPendingBillingPrefix+key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, err + } + return val, nil +} + +func (c *gatewayCache) ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) { + if c == nil || c.rdb == nil { + return false, errors.New("gateway cache unavailable") + } + key = strings.TrimSpace(key) + if key == "" { + return false, errors.New("invalid grok video billed key") + } + if ttl <= 0 { + ttl = 48 * time.Hour + } + return c.rdb.SetNX(ctx, grokVideoBilledPrefix+key, "1", ttl).Result() +} + +func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) error { + if c == nil || c.rdb == nil { + return errors.New("gateway cache unavailable") + } + key = strings.TrimSpace(key) + if key == "" { + return errors.New("invalid grok video billed key") + } + return c.rdb.Del(ctx, grokVideoBilledPrefix+key).Err() +} + // Compile-time assertion: gatewayCache must implement CyberSessionBlockStore. var _ service.CyberSessionBlockStore = (*gatewayCache)(nil) var _ service.LiveCallStore = (*gatewayCache)(nil) diff --git a/backend/internal/repository/gateway_cache_integration_test.go b/backend/internal/repository/gateway_cache_integration_test.go index 0eebc33f63..1cd4217758 100644 --- a/backend/internal/repository/gateway_cache_integration_test.go +++ b/backend/internal/repository/gateway_cache_integration_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -25,7 +24,7 @@ func (s *GatewayCacheSuite) SetupTest() { func (s *GatewayCacheSuite) TestGetSessionAccountID_Missing() { _, err := s.cache.GetSessionAccountID(s.ctx, 1, "nonexistent") - require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil for missing session") + require.True(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected ErrStickySessionNotFound for missing session") } func (s *GatewayCacheSuite) TestSetAndGetSessionAccountID() { @@ -88,7 +87,7 @@ func (s *GatewayCacheSuite) TestDeleteSessionAccountID() { require.NoError(s.T(), s.cache.DeleteSessionAccountID(s.ctx, groupID, sessionID), "DeleteSessionAccountID") _, err := s.cache.GetSessionAccountID(s.ctx, groupID, sessionID) - require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil after delete") + require.True(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected ErrStickySessionNotFound after delete") } func (s *GatewayCacheSuite) TestGetSessionAccountID_CorruptedValue() { @@ -101,7 +100,7 @@ func (s *GatewayCacheSuite) TestGetSessionAccountID_CorruptedValue() { _, err := s.cache.GetSessionAccountID(s.ctx, groupID, sessionID) require.Error(s.T(), err, "expected error for corrupted value") - require.False(s.T(), errors.Is(err, redis.Nil), "expected parsing error, not redis.Nil") + require.False(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected parsing error, not a miss") } func TestGatewayCacheSuite(t *testing.T) { diff --git a/backend/internal/repository/grok_oauth_client.go b/backend/internal/repository/grok_oauth_client.go index 38f6cfb96e..c461122e7b 100644 --- a/backend/internal/repository/grok_oauth_client.go +++ b/backend/internal/repository/grok_oauth_client.go @@ -1,10 +1,15 @@ package repository import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "net/http" "net/url" + "os" "strings" "time" @@ -20,8 +25,24 @@ type grokOAuthClient struct { tokenURL string } +const ( + accountsBaseURL = "https://accounts.x.ai" + loginRPCEndpoint = accountsBaseURL + "/api/rpc" + turnstileWebsiteURL = accountsBaseURL + turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0" + yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask" + yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult" +) + func NewGrokOAuthClient() service.GrokOAuthClient { - return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()} + // Fail closed: never fall back to an unvalidated EffectiveTokenURL (env can + // point at an attacker host and steal code/refresh tokens). + tokenURL, err := xai.ValidatedTokenURL() + if err != nil || strings.TrimSpace(tokenURL) == "" { + // Official allowlisted endpoint only — never EffectiveTokenURL() (raw env). + tokenURL = xai.DefaultTokenURL + } + return &grokOAuthClient{tokenURL: tokenURL} } func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) { @@ -90,6 +111,31 @@ func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyU return &tokenResp, nil } +// LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie. +// Password and SSO must never be written to account credentials or logs. +func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) { + turnstileToken, err := solveTurnstile(ctx) + if err != nil { + return nil, err + } + httpClient, err := createGrokHTTPClient(proxyURL, true) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err) + } + cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken) + if err != nil { + return nil, err + } + ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL) + if err != nil { + return nil, err + } + return &service.GrokPasswordLoginResult{ + Email: strings.TrimSpace(email), + SSOToken: ssoToken, + }, nil +} + func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) { client, err := createGrokSSOHTTPClient(proxyURL) if err != nil { @@ -167,6 +213,20 @@ func grokOAuthStatusError(code, message string, resp *req.Response) error { func grokOAuthHasExplicitEntitlementDenial(body string) bool { lower := strings.ToLower(body) + // Billing exhaustion is recoverable. xAI may include a generic + // access_denied code alongside the quota message, so it must win over the + // entitlement marker during token refresh. + for _, phrase := range []string{ + "spending limit", + "run out of credits", + "out of credits", + "credits exhausted", + "included free usage", + } { + if strings.Contains(lower, phrase) { + return false + } + } compact := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(lower) for _, field := range []string{"error", "code", "reason"} { for _, value := range []string{"access_denied", "entitlement_denied", "subscription_required", "no_active_subscription"} { @@ -179,3 +239,199 @@ func grokOAuthHasExplicitEntitlementDenial(body string) bool { strings.Contains(lower, "subscription required") || strings.Contains(lower, "no active grok subscription") } + +func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) { + transport := &http.Transport{} + if strings.TrimSpace(proxyURL) != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + transport.Proxy = http.ProxyURL(parsed) + } + client := &http.Client{Timeout: 120 * time.Second, Transport: transport} + if noRedirect { + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + } + return client, nil +} + +func solveTurnstile(ctx context.Context) (string, error) { + clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY")) + if clientKey == "" { + clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY")) + } + if clientKey == "" { + return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization") + } + createBody, err := json.Marshal(map[string]any{ + "clientKey": clientKey, + "task": map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": turnstileWebsiteURL, + "websiteKey": turnstileWebsiteKey, + }, + }) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody)) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + var createResp struct { + ErrorID int `json:"errorId"` + TaskID string `json:"taskId"` + ErrorDescription string `json:"errorDescription"` + } + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err) + } + if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription) + } + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Second): + } + body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID}) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body)) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + continue + } + var pollResp struct { + ErrorID int `json:"errorId"` + Status string `json:"status"` + ErrorDescription string `json:"errorDescription"` + Solution struct { + Token string `json:"token"` + } `json:"solution"` + } + err = json.NewDecoder(resp.Body).Decode(&pollResp) + _ = resp.Body.Close() + if err != nil { + continue + } + if pollResp.ErrorID != 0 { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription) + } + if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" { + return pollResp.Solution.Token, nil + } + } + return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out") +} + +func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) { + payload, err := json.Marshal(map[string]any{ + "rpc": "createSession", + "req": map[string]any{ + "createSessionRequest": map[string]any{ + "credentials": map[string]any{ + "case": "emailAndPassword", + "value": map[string]any{ + "email": email, + "clearTextPassword": password, + }, + }, + }, + "turnstileToken": turnstileToken, + }, + }) + if err != nil { + return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload)) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", accountsBaseURL) + req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true") + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Accept", "*/*") + resp, err := client.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body))) + } + var loginResp struct { + CookieSetterURL string `json:"cookieSetterUrl"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &loginResp); err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err) + } + if strings.TrimSpace(loginResp.Error) != "" { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error)) + } + if strings.TrimSpace(loginResp.CookieSetterURL) == "" { + return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl") + } + return loginResp.CookieSetterURL, nil +} + +func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) { + safeURL, err := validateGrokCookieSetterURL(cookieSetterURL) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Referer", accountsBaseURL+"/") + resp, err := client.Do(req) + if err != nil { + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + for _, cookie := range resp.Header.Values("Set-Cookie") { + if token, ok := strings.CutPrefix(cookie, "sso="); ok { + if idx := strings.Index(token, ";"); idx > 0 { + token = token[:idx] + } + return strings.TrimSpace(token), nil + } + } + return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode) +} + +func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, err + } + if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") { + return nil, fmt.Errorf("url must use https://accounts.x.ai") + } + if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" { + return nil, fmt.Errorf("url contains disallowed authority or fragment components") + } + return parsed, nil +} diff --git a/backend/internal/repository/grok_oauth_client_test.go b/backend/internal/repository/grok_oauth_client_test.go index 690a8e2139..482c3065b0 100644 --- a/backend/internal/repository/grok_oauth_client_test.go +++ b/backend/internal/repository/grok_oauth_client_test.go @@ -47,6 +47,8 @@ func TestGrokOAuthClientExchangeAndRefreshUseFormFields(t *testing.T) { } })) defer server.Close() + // Tests inject a loopback token endpoint; allowlist requires unsafe override. + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") t.Setenv(xai.EnvTokenURL, server.URL) client := NewGrokOAuthClient() @@ -80,6 +82,7 @@ func TestGrokOAuthClientRefreshForbiddenClassifiesOnlyExplicitEntitlement(t *tes _, _ = w.Write([]byte(tt.body)) })) defer server.Close() + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") t.Setenv(xai.EnvTokenURL, server.URL) client := NewGrokOAuthClient() @@ -96,6 +99,7 @@ func TestGrokOAuthClientStatusErrorRedactsSensitiveResponseBody(t *testing.T) { _, _ = w.Write([]byte(`{"error":"invalid_grant","access_token":"access-secret","refresh_token":"refresh-secret","code_verifier":"verifier-secret"}`)) })) defer server.Close() + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") t.Setenv(xai.EnvTokenURL, server.URL) client := NewGrokOAuthClient() @@ -118,4 +122,15 @@ func TestGrokOAuthEntitlementDenialRequiresExplicitEvidence(t *testing.T) { require.True(t, grokOAuthHasExplicitEntitlementDenial(`{"message":"no active Grok subscription"}`)) require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"forbidden","message":"request forbidden"}`)) require.False(t, grokOAuthHasExplicitEntitlementDenial(`403 Forbidden`)) + require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"access_denied","message":"You have run out of credits"}`)) + require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"code":"subscription_required","message":"included free usage exhausted"}`)) +} + +func TestNewGrokOAuthClient_UnvalidatedTokenURLFallsBackToDefault(t *testing.T) { + // Without unsafe overrides, a random env TokenURL must not be used (fail-closed). + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "") + t.Setenv(xai.EnvTokenURL, "https://evil.example/oauth/token") + + client := NewGrokOAuthClient().(*grokOAuthClient) + require.Equal(t, xai.DefaultTokenURL, client.tokenURL) } diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 50c9aafa5f..4cbc27909d 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -82,7 +82,12 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetNillableVideoPrice480p(groupIn.VideoPrice480P). SetNillableVideoPrice720p(groupIn.VideoPrice720P). SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). + SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)). SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall). + SetNillableSearchPricePer1k(groupIn.SearchPricePer1k). + SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin). + SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars). + SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetNillableFallbackGroupID(groupIn.FallbackGroupID). @@ -102,7 +107,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). - SetPeakRateMultiplier(groupIn.PeakRateMultiplier) + SetPeakRateMultiplier(groupIn.PeakRateMultiplier). + SetProfitControlEnabled(groupIn.ProfitControlEnabled). + SetProfitMinMargin(groupIn.ProfitMinMargin). + SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer) if groupIn.DuplicateOperationID != "" { builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID) } @@ -251,6 +259,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetNillableVideoPrice480p(groupIn.VideoPrice480P). SetNillableVideoPrice720p(groupIn.VideoPrice720P). SetNillableVideoPrice1080p(groupIn.VideoPrice1080P). + SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)). SetDefaultValidityDays(groupIn.DefaultValidityDays). SetClaudeCodeOnly(groupIn.ClaudeCodeOnly). SetModelRoutingEnabled(groupIn.ModelRoutingEnabled). @@ -268,7 +277,10 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetPeakRateEnabled(groupIn.PeakRateEnabled). SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). - SetPeakRateMultiplier(groupIn.PeakRateMultiplier) + SetPeakRateMultiplier(groupIn.PeakRateMultiplier). + SetProfitControlEnabled(groupIn.ProfitControlEnabled). + SetProfitMinMargin(groupIn.ProfitMinMargin). + SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer) // 显式处理可空字段:nil 需要 clear,非 nil 需要 set。 if groupIn.DailyLimitUSD != nil { @@ -321,6 +333,26 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er } else { builder = builder.ClearWebSearchPricePerCall() } + if groupIn.SearchPricePer1k != nil { + builder = builder.SetSearchPricePer1k(*groupIn.SearchPricePer1k) + } else { + builder = builder.ClearSearchPricePer1k() + } + if groupIn.AudioRealtimePricePerMin != nil { + builder = builder.SetAudioRealtimePricePerMin(*groupIn.AudioRealtimePricePerMin) + } else { + builder = builder.ClearAudioRealtimePricePerMin() + } + if groupIn.AudioTTSPricePerMillionChars != nil { + builder = builder.SetAudioTtsPricePerMillionChars(*groupIn.AudioTTSPricePerMillionChars) + } else { + builder = builder.ClearAudioTtsPricePerMillionChars() + } + if groupIn.AudioSTTPricePerHour != nil { + builder = builder.SetAudioSttPricePerHour(*groupIn.AudioSTTPricePerHour) + } else { + builder = builder.ClearAudioSttPricePerHour() + } // 处理 FallbackGroupID:nil 时清除,否则设置 if groupIn.FallbackGroupID != nil { diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index ec42e189f5..904ff09eb9 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -23,6 +23,7 @@ import ( "github.com/andybalholm/brotli" "github.com/klauspost/compress/zstd" + "golang.org/x/mod/semver" "golang.org/x/net/http2" "github.com/Wei-Shaw/sub2api/internal/config" @@ -30,9 +31,9 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" "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/Wei-Shaw/sub2api/internal/util/urlvalidator" - "golang.org/x/mod/semver" ) // 默认配置常量 @@ -54,6 +55,18 @@ const ( // defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟) // LLM 请求可能排队较久,需要较长超时 defaultResponseHeaderTimeout = 300 * time.Second + // defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒) + // Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段 + // 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束 + // 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。 + // 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满 + // 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。 + defaultUpstreamDialTimeout = 10 * time.Second + // defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致 + defaultUpstreamDialKeepAlive = 30 * time.Second + // defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒) + // 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待 + defaultUpstreamTLSHandshakeTimeout = 10 * time.Second // defaultMaxUpstreamClients: 默认最大客户端缓存数量 // 超出后会淘汰最久未使用的客户端 defaultMaxUpstreamClients = 5000 @@ -72,12 +85,12 @@ const ( openAIHTTP2PingTimeout = 15 * time.Second // The Grok CLI proxy rejects requests that do not identify a supported - // client version. Keep a known-good stable version in the binary while - // allowing operators to bump it without waiting for a Sub2API release. - grokCLIProxyHost = "cli-chat-proxy.grok.com" + // client version. Host/env/version pins live in package xai so service, + // billing, and transport layers advertise the same identity. + grokCLIProxyHost = xai.CLIProxyHost grokOfficialAPIHost = "api.x.ai" - grokCLIStableVersion = "0.2.93" - grokCLIVersionOverride = "XAI_GROK_CLI_VERSION" + grokCLIStableVersion = xai.CLIClientVersion // preferred pin (not the minimum floor) + grokCLIVersionOverride = xai.CLIVersionEnv grokFallbackBodyLimit = 64 << 10 ) @@ -437,6 +450,11 @@ type prefixedReadCloser struct { // the final shared transport boundary. Keying this behavior to the exact CLI // proxy host keeps direct api.x.ai traffic unchanged and automatically covers // Responses, Chat Completions, media, quota probes, and account tests. +// +// Operator overrides must be >= CLIClientVersion (the preferred pin). Package +// xai.IsSupportedCLIVersion uses a lower floor (CLIStableVersion) for general +// validation; transport is stricter so we never silently advertise an older pin +// than the binary default. func applyGrokCLIProxyHeaders(req *http.Request) { if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) { return @@ -448,14 +466,15 @@ func applyGrokCLIProxyHeaders(req *http.Request) { if !isSupportedGrokCLIVersion(version) { version = grokCLIStableVersion } - req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") + req.Header.Set("X-XAI-Token-Auth", xai.CLITokenAuth) req.Header.Set("x-grok-client-version", version) - req.Header.Set("User-Agent", "xai-grok-workspace/"+version) + req.Header.Set("x-grok-client-identifier", xai.CLIClientIdentifier) + req.Header.Set("User-Agent", xai.CLIUserAgent(version)) } func isSupportedGrokCLIVersion(version string) bool { canonical := "v" + version - minimum := "v" + grokCLIStableVersion + minimum := "v" + xai.CLIClientVersion return semver.IsValid(canonical) && semver.Canonical(canonical) == canonical && semver.Compare(canonical, minimum) >= 0 @@ -1245,6 +1264,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { } } +// newUpstreamDialer 构建上游 Transport 的 TCP dialer。 +// +// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer, +// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。 +func newUpstreamDialer() *net.Dialer { + return &net.Dialer{ + Timeout: defaultUpstreamDialTimeout, + KeepAlive: defaultUpstreamDialKeepAlive, + } +} + // buildUpstreamTransport 构建上游请求的 Transport // 使用配置文件中的连接池参数,支持生产环境调优 // @@ -1257,6 +1287,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // - error: 代理配置错误 // // Transport 参数说明: +// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传) +// - TLSHandshakeTimeout: TLS 握手超时 // - MaxIdleConns: 所有主机的最大空闲连接总数 // - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率) // - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待) @@ -1264,6 +1296,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings { // - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输) func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) { transport := &http.Transport{ + DialContext: newUpstreamDialer().DialContext, + TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout, MaxIdleConns: settings.maxIdleConns, MaxIdleConnsPerHost: settings.maxIdleConnsPerHost, MaxConnsPerHost: settings.maxConnsPerHost, diff --git a/backend/internal/repository/http_upstream_dial_timeout_test.go b/backend/internal/repository/http_upstream_dial_timeout_test.go new file mode 100644 index 0000000000..00d6ce1a41 --- /dev/null +++ b/backend/internal/repository/http_upstream_dial_timeout_test.go @@ -0,0 +1,75 @@ +package repository + +import ( + "context" + "net" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 回归:上游 Transport 必须显式配置建连超时。 +// +// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0), +// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。 +// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。 +// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。 +func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) { + settings := defaultPoolSettings(nil) + + transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer") + require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout) +} + +func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) { + dialer := newUpstreamDialer() + + require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限") + require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout) + require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive) +} + +// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext, +// 代理地址不可达时必须快速失败而不是挂满内核超时。 +func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) { + proxyURL, err := url.Parse("http://127.0.0.1:1080") + require.NoError(t, err) + + transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.Proxy) + require.NotNil(t, transport.DialContext) +} + +// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。 +func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) { + proxyURL, err := url.Parse("socks5h://127.0.0.1:1080") + require.NoError(t, err) + + transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault) + require.NoError(t, err) + require.NotNil(t, transport.DialContext) +} + +// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口, +// 断言 Dialer 走的是自己的超时路径而不是无限等待。 +// (不依赖外网可达性,CI 中确定性执行。) +func TestUpstreamDialerRespectsContextCancellation(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr) + if conn != nil { + _ = conn.Close() + } + require.Error(t, err, "已取消的 context 必须立即中止拨号") +} diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 35df747992..ac0c064b20 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -235,9 +235,9 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", 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.93", capturedHeaders.Get("User-Agent")) + require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) }) } } @@ -458,61 +458,63 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", 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.93", req.Header.Get("User-Agent")) + require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-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.95-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.95-alpha.1", req.Header.Get("User-Agent")) + 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")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\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.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", 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.92") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") 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.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + 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")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-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.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + 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")) }) + // 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.093", - "0.2.94-alpha..1", + "0.2.0115", + "0.2.115-alpha..1", "0.3", "1", - "0.2.95+build.1", + "0.2.115+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -521,8 +523,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + 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")) }) } diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index c5e13b25b1..169a01675a 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -57,6 +57,8 @@ const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_de const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key" const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index_notx.sql" 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" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -81,6 +83,14 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil "123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"), "159_batch_image_foundation.sql": newMigrationChecksumCompatibilityRule("d902b70982025ec519749faf058aab7631e82c3f48167b9a4ae4db718eb72cce", "82da85b5d98e67a0507647b873a40373e84538e4adafdeed6767c0ac8b6570b2"), "161_batch_image_pricing_snapshot.sql": newMigrationChecksumCompatibilityRule("4012af3e43636cb6af22e0176d59d1fcc70615c0f310194329461ae462c4fbd6", "96d915c9b7a6941ae99039e0ff3f1a61481eb9bddd933d11c6fadb2274554e87"), + // 195 originally seeded mode=v2; flipped to v1 (safe default / opt-in v2). Existing DBs + // that already applied the v2 seed keep their row and the historical checksum. + "195_channel_monitor_mode.sql": newMigrationChecksumCompatibilityRule("13f3792f3e3e53ee96e26415c884cf8062c77172824b54fcc9a8c0c2b1f185ec", "4c74fe33ef2274cc72e1bb49671e651274532c034b29f5b2982c2a4c88d101a6"), + // 220 originally cleared video prices for all non-grok platforms (including composite); + // composite is now preserved because it may route to Grok accounts. + "220_clear_non_grok_video_generation_config.sql": newMigrationChecksumCompatibilityRule("85e320b9ec64f2d3fcd8cf705b2b4e76a7b49f7a57140c14bff97f32691c818b", "3da48c8fdffe6390325f43d08b8e353e0a365df43d44a78dbbe655d0deb18402"), + "219_group_search_price_per_1k.sql": newMigrationChecksumCompatibilityRule("e86786ebcc3b14206fd2d321380a4e50e80cdadbfcf4962c639255e6a14008db", "df6ffd71b97e30ec2c8fe7b95e15783042dea58c553e32701ee7c42a5619af80"), + "218_group_audio_voice_pricing.sql": newMigrationChecksumCompatibilityRule("40ee9f3a2af0e0a5e99dabc878fd0fe98be1011f26bcfcefcac7197f7081f0e7", "c2a5e5b4ffd6968ad1c10593289fbc11192cdea19fec3ed9bce3a84eff9a8351"), } // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 @@ -283,6 +293,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio return dropInvalidIndexIfPresent(ctx, db, schedulerOutboxPendingDedupKeyIndex) case latestAPIKeyIPIndexMigration: return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex) + case usageLogsUpstreamModelMismatchIndexMigration: + return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex) default: return nil } diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go index 4ab6274b92..55588bb043 100644 --- a/backend/internal/repository/migrations_runner_notx_test.go +++ b/backend/internal/repository/migrations_runner_notx_test.go @@ -155,6 +155,42 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_api_key_latest_ip require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyMigrationsFS_NonTransactionalMigration_UsageModelMismatchIndexDropsInvalidIndexBeforeRetry(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(usageLogsUpstreamModelMismatchIndexMigration). + WillReturnError(sql.ErrNoRows) + mock.ExpectQuery("SELECT EXISTS \\("). + WithArgs(usageLogsUpstreamModelMismatchIndex). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_usage_logs_upstream_model_mismatch_created_at"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)"). + WithArgs(usageLogsUpstreamModelMismatchIndexMigration, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)"). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + fsys := fstest.MapFS{ + usageLogsUpstreamModelMismatchIndexMigration: &fstest.MapFile{Data: []byte(` +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at + ON usage_logs (created_at DESC, id DESC) + WHERE upstream_model_mismatch IS TRUE; +`)}, + } + + 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/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index b393bc15b6..e4af36ae25 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -76,6 +76,24 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false) requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true) requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true) + requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true) + requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true) + requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex) + + var mismatchIndexDef string + require.NoError(t, tx.QueryRowContext(context.Background(), ` +SELECT pg_get_indexdef(i.indexrelid) +FROM pg_class idx +JOIN pg_index i ON i.indexrelid = idx.oid +JOIN pg_class tbl ON tbl.oid = i.indrelid +JOIN pg_namespace ns ON ns.oid = tbl.relnamespace +WHERE ns.nspname = 'public' + AND tbl.relname = 'usage_logs' + AND idx.relname = $1 +`, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef)) + require.Contains(t, mismatchIndexDef, "created_at DESC") + require.Contains(t, mismatchIndexDef, "id DESC") + require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)") requireConstraintDefinitionContains( t, tx, diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 5eda079390..4869721f02 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -231,8 +231,7 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI WHERE proxy_id = $1 AND type = 'apikey' AND ( - (platform = 'openai' - AND extra ? 'upstream_billing_probe' + (extra ? 'upstream_billing_probe' AND extra -> 'upstream_billing_probe' <> 'null'::jsonb) OR (platform IN ('openai', 'anthropic') AND extra ? 'ollama_cloud_usage_snapshot' @@ -747,7 +746,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s rows, err = exec.QueryContext(ctx, ` UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=$1, extra=CASE - WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe' + WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, @@ -758,7 +757,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s rows, err = exec.QueryContext(ctx, ` UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=$1, extra=CASE - WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe' + WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, diff --git a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go index d4cd0ad049..2fa420bf30 100644 --- a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go +++ b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go @@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t WithArgs(int64(9)). WillReturnResult(sqlmock.NewResult(0, 0)) expectProxyUpdateReload(mock, 9, "new.example", "user", "pass") - mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). + mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18))) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")). @@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) { WithArgs(int64(9)). WillReturnResult(sqlmock.NewResult(0, 0)) expectProxyUpdateReload(mock, 9, "new.example", "", "") - mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). + mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17))) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")). diff --git a/backend/internal/repository/scheduler_cache_rate_multiplier_unit_test.go b/backend/internal/repository/scheduler_cache_rate_multiplier_unit_test.go new file mode 100644 index 0000000000..fe03a65634 --- /dev/null +++ b/backend/internal/repository/scheduler_cache_rate_multiplier_unit_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// TestSchedulerCachePreservesRateMultiplier 钉死账号调度快照的两份 payload +// (full + metadata)都必须保留 RateMultiplier。 +// +// 为什么值得一条专门的护栏:分组利润门对 nil RateMultiplier 是 fail-closed +// (保守拒绝),与全系统其他地方不同——Account.BillingRateMultiplier() 对同样 +// 的 nil 返回 1.0,字段注释也写着「nil 表示按 1.0 处理」。DB 列有 Default(1.0) +// 且非 nillable,因此 nil 只可能来自缓存反序列化缺字段。 +// buildSchedulerMetadataAccount 是显式字段清单,将来任何一次快照结构调整漏掉 +// 这一项,对启用利润控制的分组就是全组 no available accounts——而且是静默的。 +// 这条测试让那种漏列在 CI 就变红。 +func TestSchedulerCachePreservesRateMultiplier(t *testing.T) { + rate := 0.75 + account := service.Account{ + ID: 9001, + Name: "profit-gate-rate", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 2, + RateMultiplier: &rate, + } + + t.Run("metadata payload keeps the field", func(t *testing.T) { + meta := buildSchedulerMetadataAccount(account) + require.NotNil(t, meta.RateMultiplier, "metadata 快照漏掉 rate_multiplier 会让利润门 fail-closed 全组拒绝") + require.Equal(t, rate, *meta.RateMultiplier) + }) + + t.Run("both payloads survive a decode round-trip", func(t *testing.T) { + full, meta, err := marshalSchedulerCacheAccount(account) + require.NoError(t, err) + + for name, payload := range map[string][]byte{"full": full, "metadata": meta} { + decoded, decodeErr := decodeCachedAccount(payload) + require.NoError(t, decodeErr, name) + require.NotNil(t, decoded.RateMultiplier, "%s payload 反序列化后 rate_multiplier 不得为 nil", name) + require.Equal(t, rate, *decoded.RateMultiplier, name) + } + }) + + t.Run("zero rate survives as zero rather than nil", func(t *testing.T) { + // 0 是合法值(该账号上游成本为 0),绝不能被当成缺字段丢掉: + // 丢成 nil 后利润门会把一个本该必然放行的账号判成越线。 + zero := 0.0 + zeroAccount := account + zeroAccount.ID = 9002 + zeroAccount.RateMultiplier = &zero + + _, meta, err := marshalSchedulerCacheAccount(zeroAccount) + require.NoError(t, err) + decoded, err := decodeCachedAccount(meta) + require.NoError(t, err) + require.NotNil(t, decoded.RateMultiplier) + require.Equal(t, 0.0, *decoded.RateMultiplier) + }) + + t.Run("SetAccount then GetAccount preserves the field", func(t *testing.T) { + cache := newSchedulerCacheUnit(t) + ctx := context.Background() + require.NoError(t, cache.SetAccount(ctx, &account)) + + got, err := cache.GetAccount(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got) + require.NotNil(t, got.RateMultiplier, "端到端缓存读写后 rate_multiplier 不得丢失") + require.Equal(t, rate, *got.RateMultiplier) + }) +} diff --git a/backend/internal/repository/tencent_captcha_service.go b/backend/internal/repository/tencent_captcha_service.go new file mode 100644 index 0000000000..656ef30c92 --- /dev/null +++ b/backend/internal/repository/tencent_captcha_service.go @@ -0,0 +1,77 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/service" + captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" +) + +type tencentCaptchaAPI interface { + DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error) +} + +type tencentCaptchaClientFactory func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) + +type tencentCaptchaVerifier struct { + newClient tencentCaptchaClientFactory +} + +func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier { + return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient} +} + +// newTencentCaptchaSDKClient 接入点由调用方按站点下发(中国站 / 国际站)。 +// service 层的 tencentCaptchaEndpoint 已保证 endpoint 非空;即便为空, +// 腾讯 SDK 也会回落到服务默认域 captcha.tencentcloudapi.com(即中国站),不会失败。 +func newTencentCaptchaSDKClient(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) { + clientProfile := profile.NewClientProfile() + clientProfile.HttpProfile.Endpoint = endpoint + clientProfile.HttpProfile.ReqMethod = "POST" + clientProfile.HttpProfile.ReqTimeout = 5 + return captcha.NewClient(common.NewCredential(secretID, secretKey), "", clientProfile) +} + +func (v *tencentCaptchaVerifier) VerifyTicket(ctx context.Context, credentials service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, remoteIP string) (*service.TencentCaptchaVerifyResponse, error) { + client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey, credentials.Endpoint) + if err != nil { + return nil, fmt.Errorf("create tencent captcha client: %w", err) + } + request := captcha.NewDescribeCaptchaResultRequest() + request.CaptchaType = common.Uint64Ptr(9) + request.Ticket = common.StringPtr(proof.Ticket) + request.UserIp = common.StringPtr(remoteIP) + request.Randstr = common.StringPtr(proof.Randstr) + request.CaptchaAppId = common.Uint64Ptr(credentials.AppID) + request.AppSecretKey = common.StringPtr(credentials.AppSecretKey) + + response, err := client.DescribeCaptchaResultWithContext(ctx, request) + if err != nil { + return nil, fmt.Errorf("describe captcha result: %w", err) + } + if response == nil || response.Response == nil { + return nil, fmt.Errorf("describe captcha result: empty response") + } + return &service.TencentCaptchaVerifyResponse{ + CaptchaCode: valueOrZero(response.Response.CaptchaCode), + CaptchaMsg: valueOrEmpty(response.Response.CaptchaMsg), + RequestID: valueOrEmpty(response.Response.RequestId), + }, nil +} + +func valueOrZero(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/backend/internal/repository/tencent_captcha_service_test.go b/backend/internal/repository/tencent_captcha_service_test.go new file mode 100644 index 0000000000..f524dd2639 --- /dev/null +++ b/backend/internal/repository/tencent_captcha_service_test.go @@ -0,0 +1,69 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + capcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722" +) + +type tencentCaptchaAPIStub struct { + request *capcha.DescribeCaptchaResultRequest + response *capcha.DescribeCaptchaResultResponse + err error +} + +func (s *tencentCaptchaAPIStub) DescribeCaptchaResultWithContext(_ context.Context, request *capcha.DescribeCaptchaResultRequest) (*capcha.DescribeCaptchaResultResponse, error) { + s.request = request + return s.response, s.err +} + +func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) { + code := int64(1) + message := "OK" + requestID := "request-id" + client := &tencentCaptchaAPIStub{response: &capcha.DescribeCaptchaResultResponse{ + Response: &capcha.DescribeCaptchaResultResponseParams{ + CaptchaCode: &code, + CaptchaMsg: &message, + RequestId: &requestID, + }, + }} + var gotSecretID, gotSecretKey, gotEndpoint string + verifier := &tencentCaptchaVerifier{ + newClient: func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) { + gotSecretID, gotSecretKey, gotEndpoint = secretID, secretKey, endpoint + return client, nil + }, + } + + result, err := verifier.VerifyTicket(context.Background(), service.TencentCaptchaCredentials{ + AppID: 123456789, + AppSecretKey: "app-secret", + CloudSecretID: "cloud-secret-id", + CloudSecretKey: "cloud-secret-key", + Endpoint: "captcha.intl.tencentcloudapi.com", + }, service.TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, "cloud-secret-id", gotSecretID) + require.Equal(t, "cloud-secret-key", gotSecretKey) + // 接入点由 service 按站点下发,repository 不得再写死国内站 + require.Equal(t, "captcha.intl.tencentcloudapi.com", gotEndpoint) + require.NotNil(t, client.request) + require.Equal(t, uint64(9), *client.request.CaptchaType) + require.Equal(t, uint64(123456789), *client.request.CaptchaAppId) + require.Equal(t, "app-secret", *client.request.AppSecretKey) + require.Equal(t, "ticket", *client.request.Ticket) + require.Equal(t, "@rand", *client.request.Randstr) + require.Equal(t, "203.0.113.10", *client.request.UserIp) + require.Equal(t, &service.TencentCaptchaVerifyResponse{ + CaptchaCode: 1, + CaptchaMsg: "OK", + RequestID: "request-id", + }, result) +} diff --git a/backend/internal/repository/upstream_billing_probe_persistence_integration_test.go b/backend/internal/repository/upstream_billing_probe_persistence_integration_test.go index 252fa47cf9..6e7631ac0e 100644 --- a/backend/internal/repository/upstream_billing_probe_persistence_integration_test.go +++ b/backend/internal/repository/upstream_billing_probe_persistence_integration_test.go @@ -30,7 +30,7 @@ func TestAccountUpdatePreservesConcurrentProbeSnapshot(t *testing.T) { require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, stale, &service.UpstreamBillingProbeSnapshot{ Status: service.UpstreamBillingProbeStatusOK, LastAttemptAt: time.Now().UTC(), - })) + }, nil)) stale.Name = "ordinary-edit" require.NoError(t, repo.Update(ctx, stale)) @@ -46,6 +46,115 @@ func TestAccountUpdatePreservesConcurrentProbeSnapshot(t *testing.T) { require.NotContains(t, disabled.Extra, service.UpstreamBillingProbeExtraKey) } +func TestAdminAccountEditPreservesRateSynchronizedAfterLoad(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + initialRate := 0.1 + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "probe-rate-concurrent-edit", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + RateMultiplier: &initialRate, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingRateSyncEnabledExtraKey: true, + }, + }) + + staleAdminEdit, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + probeAccount, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + + synchronizedRate := 0.2 + require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, probeAccount, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusOK, + LastAttemptAt: time.Now().UTC(), + }, &synchronizedRate)) + + staleAdminEdit.Name = "name-only-edit" + require.NoError(t, repo.UpdateWithAccountBillingSettings(ctx, staleAdminEdit, nil, nil, nil)) + + got, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.Equal(t, "name-only-edit", got.Name) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, synchronizedRate, *got.RateMultiplier) +} + +func TestProbeSnapshotSyncsRateOnlyForSuccessfulEnabledAccount(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + initialRate := 0.25 + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "probe-rate-sync", + Platform: service.PlatformGemini, + Type: service.AccountTypeAPIKey, + RateMultiplier: &initialRate, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingRateSyncEnabledExtraKey: true, + }, + }) + + loaded, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + syncedRate := 0.065 + require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, loaded, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusOK, + LastAttemptAt: time.Now().UTC(), + }, &syncedRate)) + + got, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, syncedRate, *got.RateMultiplier) + + failedRate := 0.9 + require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, got, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusFailed, + LastAttemptAt: time.Now().UTC(), + }, &failedRate)) + got, err = repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, syncedRate, *got.RateMultiplier) + + require.NoError(t, repo.UpdateExtra(ctx, account.ID, map[string]any{ + service.UpstreamBillingRateSyncEnabledExtraKey: false, + })) + manual, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + manualProbeRate := 0.4 + require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, manual, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusOK, + LastAttemptAt: time.Now().UTC(), + }, &manualProbeRate)) + got, err = repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, syncedRate, *got.RateMultiplier) + + require.NoError(t, repo.UpdateExtra(ctx, account.ID, map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: false, + service.UpstreamBillingRateSyncEnabledExtraKey: false, + })) + disabled, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, disabled, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusOK, + LastAttemptAt: time.Now().UTC(), + }, &manualProbeRate)) + got, err = repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, syncedRate, *got.RateMultiplier) +} + func TestAccountUpdatePreservesConcurrentProbeEnableFlag(t *testing.T) { ctx := context.Background() tx := testEntTx(t) @@ -166,7 +275,7 @@ func TestProbeSnapshotCASIncludesLoadedEnabledState(t *testing.T) { err = repo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{ Status: service.UpstreamBillingProbeStatusOK, LastAttemptAt: time.Now().UTC(), - }) + }, nil) if tt.wantConflict { require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged) } else { @@ -183,6 +292,46 @@ func TestProbeSnapshotCASIncludesLoadedEnabledState(t *testing.T) { } } +func TestProbeSnapshotCASProtectsManualRateAfterSyncDisabled(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + initialRate := 0.25 + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "probe-sync-cas", + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + RateMultiplier: &initialRate, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingRateSyncEnabledExtraKey: true, + }, + }) + + inFlight, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + manual, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + manualRate := 0.8 + syncDisabled := false + require.NoError(t, repo.UpdateWithAccountBillingSettings(ctx, manual, nil, &syncDisabled, &manualRate)) + + probedRate := 0.1 + err = repo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{ + Status: service.UpstreamBillingProbeStatusOK, + LastAttemptAt: time.Now().UTC(), + }, &probedRate) + require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged) + + got, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, got.RateMultiplier) + require.Equal(t, manualRate, *got.RateMultiplier) + require.Equal(t, false, got.Extra[service.UpstreamBillingRateSyncEnabledExtraKey]) + require.NotContains(t, got.Extra, service.UpstreamBillingProbeExtraKey) +} + func boolPtr(value bool) *bool { return &value } @@ -249,7 +398,7 @@ func TestProxyIdentityUpdateInvalidatesProbeAndRejectsInFlightSnapshot(t *testin err = accountRepo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{ Status: service.UpstreamBillingProbeStatusOK, LastAttemptAt: time.Now().UTC(), - }) + }, nil) require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged) rows, err := tx.QueryContext(ctx, ` diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 809b0652b5..6d932c0d43 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -31,6 +31,8 @@ var usageLogInsertArgTypes = [...]string{ "text", // model "text", // requested_model "text", // upstream_model + "text", // upstream_response_model + "boolean", // upstream_model_mismatch "bigint", // group_id "bigint", // subscription_id "integer", // input_tokens @@ -227,6 +229,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -278,12 +282,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, session_id, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, + $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -682,6 +686,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -734,9 +740,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - // Each batch row prepends the synthetic input_index before the 57 + // Each batch row prepends the synthetic input_index before the 59 // usage-log column values. - args := make([]any, 0, len(keys)*58) + args := make([]any, 0, len(keys)*60) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -772,6 +778,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -831,6 +839,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -930,6 +940,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -982,7 +994,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*57) + args := make([]any, 0, len(preparedList)*59) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1015,6 +1027,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1074,6 +1088,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1141,6 +1157,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared model, requested_model, upstream_model, + upstream_response_model, + upstream_model_mismatch, group_id, subscription_id, input_tokens, @@ -1192,12 +1210,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared session_id, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9, - $10, $11, $12, $13, - $14, $15, $16, $17, - $18, $19, $20, $21, $22, $23, - $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57 + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, + $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1244,6 +1262,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { requestedModel = strings.TrimSpace(log.Model) } upstreamModel := nullString(log.UpstreamModel) + upstreamResponseModel := nullString(log.UpstreamResponseModel) + upstreamModelMismatch := nullBool(log.UpstreamModelMismatch) var requestIDArg any if requestID != "" { @@ -1263,6 +1283,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { log.Model, nullString(&requestedModel), upstreamModel, + upstreamResponseModel, + upstreamModelMismatch, groupID, subscriptionID, log.InputTokens, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 084b79ad97..f2fa6e30b0 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -127,6 +127,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat args = append(args, int16(*filters.BillingType)) } conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.UpstreamModelMismatch != nil { + conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch)) + } if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) @@ -157,6 +160,13 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat return logs, page, nil } +func upstreamModelMismatchCondition(column string, mismatch bool) string { + if mismatch { + return column + " IS TRUE" + } + return column + " IS FALSE" +} + func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool { if filters.ExactTotal { return false @@ -437,6 +447,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e model string requestedModel sql.NullString upstreamModel sql.NullString + upstreamResponseModel sql.NullString + upstreamModelMismatch sql.NullBool groupID sql.NullInt64 subscriptionID sql.NullInt64 inputTokens int @@ -498,6 +510,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &model, &requestedModel, &upstreamModel, + &upstreamResponseModel, + &upstreamModelMismatch, &groupID, &subscriptionID, &inputTokens, @@ -651,6 +665,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if upstreamModel.Valid { log.UpstreamModel = &upstreamModel.String } + if upstreamResponseModel.Valid { + log.UpstreamResponseModel = &upstreamResponseModel.String + } + if upstreamModelMismatch.Valid { + value := upstreamModelMismatch.Bool + log.UpstreamModelMismatch = &value + } if channelID.Valid { value := channelID.Int64 log.ChannelID = &value @@ -703,6 +724,13 @@ func nullString(v *string) sql.NullString { return sql.NullString{String: *v, Valid: true} } +func nullBool(v *bool) sql.NullBool { + if v == nil { + return sql.NullBool{} + } + return sql.NullBool{Bool: *v, Valid: true} +} + func nullStringIntMapJSON(v map[string]int) any { if len(v) == 0 { return nil 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 9c59b38e54..a2b82dfa60 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -48,6 +48,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { log.Model, log.RequestedModel, sqlmock.AnyArg(), // upstream_model + sqlmock.AnyArg(), // upstream_response_model + sqlmock.AnyArg(), // upstream_model_mismatch sqlmock.AnyArg(), // group_id sqlmock.AnyArg(), // subscription_id log.InputTokens, @@ -137,9 +139,11 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { log.RequestID, log.Model, log.RequestedModel, - sqlmock.AnyArg(), - sqlmock.AnyArg(), - sqlmock.AnyArg(), + sqlmock.AnyArg(), // upstream_model + sqlmock.AnyArg(), // upstream_response_model + sqlmock.AnyArg(), // upstream_model_mismatch + sqlmock.AnyArg(), // group_id + sqlmock.AnyArg(), // subscription_id log.InputTokens, log.OutputTokens, log.CacheCreationTokens, @@ -211,8 +215,8 @@ func TestBuildUsageLogBestEffortInsertQuery_IncludesRequestedModelColumn(t *test query, args := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared}) require.Contains(t, query, "INSERT INTO usage_logs (") - require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,") - require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,") + require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,") + require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,") require.Len(t, args, len(prepared.args)) require.Equal(t, prepared.args[5], args[5]) } @@ -273,11 +277,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) { CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC), }) - require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36]) - require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37]) - require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38]) - require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39]) - breakdownJSON, ok := prepared.args[40].(string) + require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[38]) + require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[39]) + require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[40]) + require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[41]) + breakdownJSON, ok := prepared.args[42].(string) require.True(t, ok) require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON) } @@ -713,10 +717,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) end := start.Add(24 * time.Hour) - rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}). - AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)). - AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)). - AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600)) + rows := sqlmock.NewRows([]string{"user_id", "email", "username", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}). + AddRow(int64(2), "beta@example.com", "beta", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)). + AddRow(int64(1), "alpha@example.com", "alpha", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)). + AddRow(int64(3), "gamma@example.com", "", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600)) mock.ExpectQuery("WITH user_spend AS \\("). WithArgs(start, end, 12). @@ -726,8 +730,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { require.NoError(t, err) require.Equal(t, &usagestats.UserSpendingRankingResponse{ Ranking: []usagestats.UserSpendingRankingItem{ - {UserID: 2, Email: "beta@example.com", ActualCost: 12.5, Requests: 9, Tokens: 900}, - {UserID: 1, Email: "alpha@example.com", ActualCost: 12.5, Requests: 8, Tokens: 800}, + {UserID: 2, Email: "beta@example.com", Username: "beta", ActualCost: 12.5, Requests: 9, Tokens: 900}, + {UserID: 1, Email: "alpha@example.com", Username: "alpha", ActualCost: 12.5, Requests: 8, Tokens: 800}, {UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300}, }, TotalActualCost: 40.0, @@ -809,6 +813,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-image-2", sql.NullString{Valid: true, String: "gpt-image-2"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 0, 0, 0, 0, 0, 0, @@ -872,6 +878,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5", // model sql.NullString{Valid: true, String: "gpt-5"}, // requested_model sql.NullString{}, // upstream_model + sql.NullString{}, // upstream_response_model + sql.NullBool{}, // upstream_model_mismatch sql.NullInt64{}, // group_id sql.NullInt64{}, // subscription_id 1, // input_tokens @@ -942,6 +950,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5", sql.NullString{Valid: true, String: "gpt-5"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, @@ -1000,6 +1010,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { "gpt-5.4", sql.NullString{Valid: true, String: "gpt-5.4"}, sql.NullString{}, + sql.NullString{}, + sql.NullBool{}, sql.NullInt64{}, sql.NullInt64{}, 1, 2, 3, 4, 5, 6, diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index aeccd49b30..53d60b7f4a 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -683,6 +683,9 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us args = append(args, int16(*filters.BillingType)) } conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode) + if filters.UpstreamModelMismatch != nil { + conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch)) + } if filters.StartTime != nil { conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) args = append(args, *filters.StartTime) 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 44052d07cc..e9025b1bd1 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -4,6 +4,7 @@ package repository import ( "context" + "strings" "testing" "time" @@ -12,6 +13,66 @@ import ( "github.com/stretchr/testify/require" ) +func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + client := tx.Client() + repo := newUsageLogRepositoryWithSQL(client, tx) + + user := mustCreateUser(t, client, &service.User{Email: "model-audit@test.com"}) + apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-model-audit", Name: "model-audit"}) + account := mustCreateAccount(t, client, &service.Account{Name: "model-audit-account"}) + now := time.Now().UTC() + responseModel := "gpt-5.4" + for _, mismatch := range []bool{true, false} { + mismatchValue := mismatch + _, err := repo.Create(ctx, &service.UsageLog{ + UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID, + Model: "gpt-5.5", InputTokens: 1, OutputTokens: 1, + UpstreamResponseModel: &responseModel, UpstreamModelMismatch: &mismatchValue, + CreatedAt: now, + }) + require.NoError(t, err) + } + + start := now.Add(-time.Hour) + end := now.Add(time.Hour) + trueValue := true + stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{ + UserID: user.ID, StartTime: &start, EndTime: &end, UpstreamModelMismatch: &trueValue, + }) + require.NoError(t, err) + require.Equal(t, int64(1), stats.TotalRequests) + + trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{ + UserID: user.ID, UpstreamModelMismatch: &trueValue, + }) + require.NoError(t, err) + require.Len(t, trend, 1) + require.Equal(t, int64(1), trend[0].Requests) + + _, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off") + require.NoError(t, err) + rows, err := tx.QueryContext(ctx, ` +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) +} + func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { ctx := context.Background() tx := testEntTx(t) diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index ae431c7061..6d8bd79eaa 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -150,18 +150,20 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi SELECT u.user_id, COALESCE(us.email, '') as email, + COALESCE(us.username, '') as username, COALESCE(SUM(u.actual_cost), 0) as actual_cost, COUNT(*) as requests, COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens FROM usage_logs u LEFT JOIN users us ON u.user_id = us.id WHERE u.created_at >= $1 AND u.created_at < $2 - GROUP BY u.user_id, us.email + GROUP BY u.user_id, us.email, us.username ), ranked AS ( SELECT user_id, email, + username, actual_cost, requests, tokens, @@ -175,6 +177,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi SELECT user_id, email, + username, actual_cost, requests, tokens, @@ -202,7 +205,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi totalTokens := int64(0) for rows.Next() { var row UserSpendingRankingItem - if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { + if err = rows.Scan(&row.UserID, &row.Email, &row.Username, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { return nil, err } ranking = append(ranking, row) @@ -262,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user // GetUserModelStats 获取指定用户的模型统计 func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil) } // GetUsageTrendWithFilters returns usage trend data with optional filters func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil) } func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) { - if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) { +func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) { + if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) { aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) if aggregatedErr == nil && len(aggregated) > 0 { return aggregated, nil @@ -323,6 +326,9 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch) + } query += " GROUP BY date ORDER BY date ASC" rows, err := r.sql.QueryContext(ctx, query, args...) @@ -345,7 +351,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start return results, nil } -func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool { +func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool { if granularity != "day" && granularity != "hour" { return false } @@ -357,7 +363,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID requestType == nil && stream == nil && billingType == nil && - billingMode == "" + billingMode == "" && + upstreamModelMismatch == nil } func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { @@ -422,20 +429,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil) } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. // source: requested | upstream | mapping. func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "") + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil) } func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) { +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 if accountID > 0 && userID == 0 && apiKeyID == 0 { @@ -487,6 +494,9 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch) + } query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr) rows, err := r.sql.QueryContext(ctx, query, args...) @@ -511,14 +521,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex // GetGroupStatsWithFilters returns group usage statistics with optional filters func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "") + return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil) } func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) + return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) } -func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) { +func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) { query := ` SELECT COALESCE(ul.group_id, 0) as group_id, @@ -561,6 +571,9 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start args = append(args, int16(*billingType)) } query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul") + if upstreamModelMismatch != nil { + query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch) + } query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC" rows, err := r.sql.QueryContext(ctx, query, args...) diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go index 7804dce306..64c1c1bab2 100644 --- a/backend/internal/repository/usage_log_session_id_unit_test.go +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog { // arg slice / arg-type table so the five INSERT column lists stay in sync. session_id // is the penultimate arg (created_at is always last). func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) { - require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id") + require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id") sessionID := "sess-persisted-123" prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID)) diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 84baac5786..def7462c12 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -43,16 +43,27 @@ func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepos } func (r *userRepository) Create(ctx context.Context, userIn *service.User) error { - return r.create(ctx, userIn, false) + return r.create(ctx, userIn, false, "") } // CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份, // 供注册路径使用。 func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error { - return r.create(ctx, userIn, true) + return r.create(ctx, userIn, true, "") } -func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool) error { +// CountUsersByEmailDomain 统计指定可注册主域名及其子域名下的未删除用户。 +func (r *userRepository) CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) { + return countUsersByEmailDomainWithClient(ctx, clientFromContext(ctx, r.client), domain) +} + +// CreateWithEmailAliasGuardAndDomainLimit 串行化非白名单域名的注册请求, +// 并在用户写入的同一事务内复查域名额度。 +func (r *userRepository) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, userIn *service.User, domain string) error { + return r.create(ctx, userIn, true, normalizeEmailDomain(domain)) +} + +func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool, domainLimit string) error { if userIn == nil { return nil } @@ -84,6 +95,9 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard // 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。 lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email)) } + if domainLimit != "" { + lockKeys = append(lockKeys, registrationEmailDomainLockKey(domainLimit)) + } releaseEmailLock, err := lockRepositoryScopedKeys( txCtx, txClient, @@ -95,6 +109,16 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard } defer releaseEmailLock() + if domainLimit != "" { + count, err := countUsersByEmailDomainWithClient(txCtx, txClient, domainLimit) + if err != nil { + return err + } + if count > 0 { + return service.ErrEmailDomainRegistrationLimit + } + } + if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, 0, userIn.Email); err != nil { return err } @@ -859,6 +883,49 @@ func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount flo return nil } +// DeductAvailableBalance atomically deducts min(amount, max(balance, 0)). +// Unlike DeductBalance, this refund-specific operation never increases an +// existing deficit or permits a concurrent deduction to cause an overdraft. +func (r *userRepository) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (deducted float64, err error) { + if amount < 0 { + return 0, fmt.Errorf("deduction amount must be nonnegative") + } + const updateSQL = ` + WITH target AS ( + SELECT id, balance + FROM users + WHERE id = $2 AND deleted_at IS NULL + FOR UPDATE + ), updated AS ( + UPDATE users AS u + SET balance = target.balance - LEAST($1, GREATEST(target.balance, 0)), updated_at = NOW() + FROM target + WHERE u.id = target.id AND u.deleted_at IS NULL + RETURNING target.balance - u.balance AS deducted + ) + SELECT deducted FROM updated + ` + rows, err := clientFromContext(ctx, r.client).QueryContext(ctx, updateSQL, amount, id) + if err != nil { + return 0, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + } + }() + if !rows.Next() { + if rowsErr := rows.Err(); rowsErr != nil { + return 0, rowsErr + } + return 0, service.ErrUserNotFound + } + if err := rows.Scan(&deducted); err != nil { + return 0, err + } + return deducted, rows.Err() +} + // AdjustBalance 原子地把 delta 累加到余额上,结果为负时整条语句不生效。 // 相比"读余额 → 算新值 → 整行写回",这里把读与写压进同一条 UPDATE, // 并发的计费扣款不会被旧快照覆盖。 @@ -1189,6 +1256,48 @@ func normalizedEmailUniquenessLockKey(email string) string { return "users:normalized-email:" + normalized } +func registrationEmailDomainLockKey(domain string) string { + domain = normalizeEmailDomain(domain) + if domain == "" { + return "" + } + return "users:registration-email-domain:" + domain +} + +func normalizeEmailDomain(domain string) string { + return service.NormalizeRegistrationEmailDomain(domain) +} + +func countUsersByEmailDomainWithClient(ctx context.Context, client *dbent.Client, domain string) (int, error) { + client = clientFromContext(ctx, client) + domain = normalizeEmailDomain(domain) + if client == nil || domain == "" { + return 0, nil + } + return client.User.Query().Where(userEmailDomainPredicate(domain)).Count(ctx) +} + +func userEmailDomainPredicate(domain string) predicate.User { + domain = normalizeEmailDomain(domain) + escapedDomain := escapeLikeWildcards(domain) + exactPattern := "%@" + escapedDomain + subdomainPattern := "%@%." + escapedDomain + return predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + b.WriteString("(RTRIM(LOWER(TRIM("). + Ident(s.C(dbuser.FieldEmail)). + WriteString(")), '.') LIKE "). + Arg(exactPattern). + WriteString(` ESCAPE '\' OR RTRIM(LOWER(TRIM(`). + Ident(s.C(dbuser.FieldEmail)). + WriteString(")), '.') LIKE "). + Arg(subdomainPattern). + WriteString(` ESCAPE '\'`). + WriteString(")") + })) + }) +} + // emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同 // 别名变体在注册时互斥。 func emailAliasUniquenessLockKey(email string) string { diff --git a/backend/internal/repository/user_repo_email_alias_test.go b/backend/internal/repository/user_repo_email_alias_test.go index 6faf7340d2..a32144ba81 100644 --- a/backend/internal/repository/user_repo_email_alias_test.go +++ b/backend/internal/repository/user_repo_email_alias_test.go @@ -98,3 +98,58 @@ func TestUserRepositoryCreateWithEmailAliasGuard(t *testing.T) { Status: service.StatusActive, })) } + +func TestUserRepositoryCountUsersByEmailDomain(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + + active := &service.User{ + Email: "first@custom.example", + Username: "first", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + } + seedUserForAliasTest(t, repo, active.Email) + seedUserForAliasTest(t, repo, "other@sub.custom.example") + deleted := &service.User{ + Email: "deleted@custom.example", + Username: "deleted", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + } + require.NoError(t, repo.Create(ctx, deleted)) + require.NoError(t, repo.Delete(ctx, deleted.ID)) + + count, err := repo.CountUsersByEmailDomain(ctx, "custom.example") + require.NoError(t, err) + require.Equal(t, 2, count) +} + +func TestUserRepositoryCreateWithEmailAliasGuardAndDomainLimit(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + seedUserForAliasTest(t, repo, "first@custom.example.") + + err := repo.CreateWithEmailAliasGuardAndDomainLimit(ctx, &service.User{ + Email: "second@custom.example", + Username: "second", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + }, "custom.example") + require.ErrorIs(t, err, service.ErrEmailDomainRegistrationLimit) +} + +func TestUserRepositoryCountUsersByEmailDomainEscapesLikeWildcards(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + seedUserForAliasTest(t, repo, "first@foo_bar.com") + seedUserForAliasTest(t, repo, "other@fooxbar.com") + + count, err := repo.CountUsersByEmailDomain(ctx, "foo_bar.com") + + require.NoError(t, err) + require.Equal(t, 1, count) +} diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index 998b2577e2..23dbc2e843 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -4,6 +4,8 @@ package repository import ( "context" + "errors" + "strings" "sync" "testing" "time" @@ -63,6 +65,44 @@ func (s *UserRepoSuite) mustCreateUser(u *service.User) *service.User { return u } +func (s *UserRepoSuite) TestCreateWithEmailAliasGuardAndDomainLimitConcurrent() { + domain := "race-" + strings.ToLower(strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) + ".example" + users := []*service.User{ + {Email: "first@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive}, + {Email: "second@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive}, + } + + errs := make(chan error, len(users)) + var wg sync.WaitGroup + for _, user := range users { + wg.Add(1) + go func(user *service.User) { + defer wg.Done() + errs <- s.repo.CreateWithEmailAliasGuardAndDomainLimit(s.ctx, user, domain) + }(user) + } + wg.Wait() + close(errs) + + var success, limited int + for err := range errs { + switch { + case err == nil: + success++ + case errors.Is(err, service.ErrEmailDomainRegistrationLimit): + limited++ + default: + s.Require().NoError(err) + } + } + s.Require().Equal(1, success) + s.Require().Equal(1, limited) + + count, err := s.repo.CountUsersByEmailDomain(s.ctx, domain) + s.Require().NoError(err) + s.Require().Equal(1, count) +} + func (s *UserRepoSuite) mustCreateGroup(name string) *service.Group { s.T().Helper() @@ -479,6 +519,30 @@ func (s *UserRepoSuite) TestDeductBalance_AllowsOverdraft() { s.Require().InDelta(-5.0, got.Balance, 1e-6, "Balance should be -5.0 after overdraft") } +func (s *UserRepoSuite) TestDeductAvailableBalance_ClampsToNonnegativeBalance() { + for _, tc := range []struct { + name string + balance float64 + requested float64 + wantDeduct float64 + wantBalance float64 + }{ + {name: "enough balance", balance: 10, requested: 4, wantDeduct: 4, wantBalance: 6}, + {name: "insufficient balance", balance: 5, requested: 10, wantDeduct: 5, wantBalance: 0}, + {name: "negative balance unchanged", balance: -3, requested: 10, wantDeduct: 0, wantBalance: -3}, + } { + s.Run(tc.name, func() { + user := s.mustCreateUser(&service.User{Email: "available-" + strings.ReplaceAll(tc.name, " ", "-") + "@test.com", Balance: tc.balance}) + deducted, err := s.repo.DeductAvailableBalance(s.ctx, user.ID, tc.requested) + s.Require().NoError(err) + s.Require().InDelta(tc.wantDeduct, deducted, 1e-6) + got, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Require().InDelta(tc.wantBalance, got.Balance, 1e-6) + }) + } +} + // --- Concurrency --- func (s *UserRepoSuite) TestUpdateConcurrency() { diff --git a/backend/internal/repository/user_subscription_lock_test.go b/backend/internal/repository/user_subscription_lock_test.go new file mode 100644 index 0000000000..b7d2e59d52 --- /dev/null +++ b/backend/internal/repository/user_subscription_lock_test.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + _ "github.com/Wei-Shaw/sub2api/ent/runtime" + "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/stretchr/testify/require" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" +) + +func TestUserSubscriptionGetByIDForUpdateLocksRow(t *testing.T) { + var capturedSQL string + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(captureEntQueryMatcher{actual: &capturedSQL})) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + driver := entsql.OpenDB(dialect.Postgres, db) + client := dbent.NewClient(dbent.Driver(driver)) + t.Cleanup(func() { _ = client.Close() }) + repo := NewUserSubscriptionRepository(client) + now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + + mock.ExpectQuery("locked subscription").WillReturnRows( + sqlmock.NewRows(usersubscription.Columns).AddRow( + int64(7), now, now, nil, int64(11), int64(13), now, now.AddDate(0, 0, 30), "active", + nil, nil, nil, 0.0, 0.0, 0.0, nil, now, "renewal", + ), + ) + + sub, err := repo.GetByIDForUpdate(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, int64(7), sub.ID) + require.NoError(t, mock.ExpectationsWereMet()) + require.Contains(t, strings.ToUpper(normalizeSQLWhitespace(capturedSQL)), "FOR UPDATE") +} diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 37f06a038b..0af3eda985 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -75,6 +75,18 @@ func (r *userSubscriptionRepository) GetByID(ctx context.Context, id int64) (*se return userSubscriptionEntityToService(m), nil } +func (r *userSubscriptionRepository) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + client := clientFromContext(ctx, r.client) + m, err := client.UserSubscription.Query(). + Where(usersubscription.IDEQ(id)). + ForUpdate(). + Only(ctx) + if err != nil { + return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) + } + return userSubscriptionEntityToService(m), nil +} + func (r *userSubscriptionRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { client := clientFromContext(ctx, r.client) queryCtx := mixins.SkipSoftDelete(ctx) @@ -356,27 +368,33 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) } -func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { client := clientFromContext(ctx, r.client) - _, err := client.UserSubscription.UpdateOneID(id). - SetDailyWindowStart(start). - SetWeeklyWindowStart(start). - SetMonthlyWindowStart(start). + n, err := client.UserSubscription.Update(). + Where( + usersubscription.IDEQ(id), + usersubscription.DailyWindowStartIsNil(), + usersubscription.WeeklyWindowStartIsNil(), + usersubscription.MonthlyWindowStartIsNil(), + ). + SetDailyWindowStart(dailyStart). + SetWeeklyWindowStart(periodicStart). + SetMonthlyWindowStart(periodicStart). Save(ctx) - return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) + return r.translateConditionalWindowReset(ctx, client, id, n, err) } -func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error { +func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { client := clientFromContext(ctx, r.client) update := client.UserSubscription.UpdateOneID(id) if resetDaily { - update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart) + update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart) } if resetWeekly { - update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart) + update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart) } if resetMonthly { - update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart) + update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart) } _, err := update.Save(ctx) return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 96eead494e..28f4c1b7d3 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { group := s.mustCreateGroup("g-activate") sub := s.mustCreateSubscription(user.ID, group.ID, nil) + dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt) + err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt) s.Require().NoError(err, "ActivateWindows") got, err := s.repo.GetByID(s.ctx, sub.ID) @@ -460,7 +461,29 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { s.Require().NotNil(got.DailyWindowStart) s.Require().NotNil(got.WeeklyWindowStart) s.Require().NotNil(got.MonthlyWindowStart) - s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond) + s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond) +} + +func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() { + user := s.mustCreateUser("activate-cas@test.com", service.RoleUser) + group := s.mustCreateGroup("g-activate-cas") + sub := s.mustCreateSubscription(user.ID, group.ID, nil) + activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + manualResetAt := activatedAt.Add(2 * time.Hour) + manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt)) + s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt)) + // Simulate a concurrent request carrying the original unactivated snapshot. + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour))) + + got, err := s.repo.GetByID(s.ctx, sub.ID) + s.Require().NoError(err) + s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond) } func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() { @@ -516,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma newWindowStart := oldWindowStart.Add(24 * time.Hour) s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart)) s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3)) - s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart)) + s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart)) got, err := s.repo.GetByID(s.ctx, sub.ID) s.Require().NoError(err) @@ -751,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba s.Require().Equal(active.ID, got.ID, "expected active subscription") activateAt := time.Now().Add(-25 * time.Hour) - s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows") + s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows") s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage") after, err := s.repo.GetByID(s.ctx, active.ID) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 782b1b9ce5..34c1a1b9d2 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -98,6 +98,7 @@ var ProviderSet = wire.NewSet( NewTLSFingerprintProfileRepository, NewChannelRepository, NewChannelMonitorRepository, + NewChannelMonitorV2Repository, NewChannelMonitorRequestTemplateRepository, NewContentModerationRepository, NewAffiliateRepository, @@ -149,6 +150,8 @@ var ProviderSet = wire.NewSet( // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, + NewTencentCaptchaVerifier, + NewAliyunCaptchaVerifier, ProvidePricingRemoteClient, ProvideGitHubReleaseClient, NewProxyExitInfoProber, diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index c7c9f24ac5..92603d9ff4 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -64,20 +64,21 @@ type StorageEndpoint struct { } type storageConfig struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []StorageEndpoint `json:"endpoints"` - ConfigVersion int64 `json:"config_version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy int64 `json:"updated_by"` - ChangeSummary string `json:"change_summary"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []StorageEndpoint `json:"endpoints"` + ConfigVersion int64 `json:"config_version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` + ChangeSummary string `json:"change_summary"` } type ActiveEndpoint struct { @@ -98,21 +99,22 @@ type ActiveEndpoint struct { } type ActiveConfig struct { - RiskControlEnabled bool - Enabled bool - BlockingEnabled bool - StorePassEvents bool - Strategy string - WorkerCount int - QueueCapacity int - Scanners []string - AllGroups bool - GroupIDs []int64 - Endpoints []ActiveEndpoint - ConfigVersion int64 - UpdatedAt time.Time - UpdatedBy int64 - ChangeSummary string + RiskControlEnabled bool + Enabled bool + BlockingEnabled bool + BlockingLatestTurnOnly bool + StorePassEvents bool + Strategy string + WorkerCount int + QueueCapacity int + Scanners []string + AllGroups bool + GroupIDs []int64 + Endpoints []ActiveEndpoint + ConfigVersion int64 + UpdatedAt time.Time + UpdatedBy int64 + ChangeSummary string } type PublicEndpoint struct { @@ -129,21 +131,22 @@ type PublicEndpoint struct { } type PublicConfig struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - EffectiveMode Mode `json:"effective_mode"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []PublicEndpoint `json:"endpoints"` - ConfigVersion int64 `json:"config_version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy int64 `json:"updated_by"` - ChangeSummary string `json:"change_summary"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + EffectiveMode Mode `json:"effective_mode"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []PublicEndpoint `json:"endpoints"` + ConfigVersion int64 `json:"config_version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` + ChangeSummary string `json:"change_summary"` } type UpdateEndpoint struct { @@ -160,32 +163,34 @@ type UpdateEndpoint struct { } type UpdateConfigRequest struct { - ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"` - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []UpdateEndpoint `json:"endpoints"` + ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []UpdateEndpoint `json:"endpoints"` } func DefaultStorageConfig() storageConfig { return storageConfig{ - Enabled: false, - BlockingEnabled: false, - StorePassEvents: false, - Strategy: "priority", - WorkerCount: DefaultWorkerCount, - QueueCapacity: DefaultQueueCapacity, - Scanners: append([]string(nil), AllScannerIDs...), - AllGroups: true, - GroupIDs: []int64{}, - Endpoints: []StorageEndpoint{}, - ConfigVersion: 1, + Enabled: false, + BlockingEnabled: false, + BlockingLatestTurnOnly: false, + StorePassEvents: false, + Strategy: "priority", + WorkerCount: DefaultWorkerCount, + QueueCapacity: DefaultQueueCapacity, + Scanners: append([]string(nil), AllScannerIDs...), + AllGroups: true, + GroupIDs: []int64{}, + Endpoints: []StorageEndpoint{}, + ConfigVersion: 1, } } @@ -407,7 +412,7 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE } active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled} return PublicConfig{ - Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, StorePassEvents: cfg.StorePassEvents, + Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents, EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups, GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion, @@ -418,7 +423,8 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) { active := ActiveConfig{ RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, - StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, + BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, + StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups, GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion, UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary, @@ -454,15 +460,16 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec func changeSummary(cfg storageConfig) string { summary := struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - EndpointCount int `json:"endpoint_count"` - ScannerCount int `json:"scanner_count"` - AllGroups bool `json:"all_groups"` - GroupCount int `json:"group_count"` - GroupHash string `json:"group_hash"` - }{cfg.Enabled, cfg.BlockingEnabled, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""} + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + EndpointCount int `json:"endpoint_count"` + ScannerCount int `json:"scanner_count"` + AllGroups bool `json:"all_groups"` + GroupCount int `json:"group_count"` + GroupHash string `json:"group_hash"` + }{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""} rawGroups, _ := json.Marshal(cfg.GroupIDs) digest := sha256.Sum256(rawGroups) summary.GroupHash = hex.EncodeToString(digest[:]) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index b832323e8c..2ce102296a 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -335,7 +335,7 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig currentByID[endpoint.ID] = endpoint } next := storageConfig{ - Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, StorePassEvents: req.StorePassEvents, + Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents, Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount, QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...), AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...), diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 96cc5a5533..48175947c2 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -32,6 +32,7 @@ func TestDefaultConfigIsOff(t *testing.T) { storage, err := ParseStorageConfig("") require.NoError(t, err) require.False(t, storage.Enabled) + require.False(t, storage.BlockingLatestTurnOnly) active, err := ActiveFromStorage(storage, true, prefixEncryptor{}) require.NoError(t, err) require.Equal(t, ModeOff, active.EffectiveMode()) @@ -42,6 +43,28 @@ func TestDefaultConfigIsOff(t *testing.T) { require.Contains(t, string(publicJSON), `"endpoints":[]`) } +func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) { + manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true} + request := UpdateConfigRequest{ + ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, + Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true, + Endpoints: []UpdateEndpoint{{ + ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", + Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true, + }}, + } + next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9) + require.NoError(t, err) + require.True(t, next.BlockingLatestTurnOnly) + require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`) + + active, err := ActiveFromStorage(next, true, prefixEncryptor{}) + require.NoError(t, err) + require.True(t, active.BlockingLatestTurnOnly) + public := PublicFromStorage(next, true, nil) + require.True(t, public.BlockingLatestTurnOnly) +} + func TestConfigRejectsBlockingWithoutAudit(t *testing.T) { storage := DefaultStorageConfig() storage.BlockingEnabled = true diff --git a/backend/internal/securityaudit/prompt_handler.go b/backend/internal/securityaudit/prompt_handler.go index 24284aa540..342f3a5461 100644 --- a/backend/internal/securityaudit/prompt_handler.go +++ b/backend/internal/securityaudit/prompt_handler.go @@ -228,7 +228,8 @@ func configAuditFields(request UpdateConfigRequest, saved *PublicConfig) map[str } return map[string]any{ "enabled": request.Enabled, "blocking_enabled": request.BlockingEnabled, - "config_version": version, "endpoint_count": len(request.Endpoints), + "blocking_latest_turn_only": request.BlockingLatestTurnOnly, + "config_version": version, "endpoint_count": len(request.Endpoints), "scanner_count": len(request.Scanners), "all_groups": request.AllGroups, "group_count": len(request.GroupIDs), } diff --git a/backend/internal/securityaudit/prompt_qwen3guard.go b/backend/internal/securityaudit/prompt_qwen3guard.go index aa3e9ea9d6..90d81cff33 100644 --- a/backend/internal/securityaudit/prompt_qwen3guard.go +++ b/backend/internal/securityaudit/prompt_qwen3guard.go @@ -86,19 +86,13 @@ func NormalizeCategory(value string) string { } func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) { - lines := make([]string, 0, 2) - for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { - line = strings.TrimSpace(line) - if line != "" { - lines = append(lines, line) - } - } - if len(lines) != 2 { - return nil, &GuardError{Code: ErrorCodeInvalidResponse, Retryable: false} - } var safety string var categoryLine string - for _, line := range lines { + for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } lower := strings.ToLower(line) switch { case strings.HasPrefix(lower, "safety:"): @@ -112,7 +106,7 @@ func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResul } categoryLine = strings.TrimSpace(line[len("categories:"):]) default: - return nil, &GuardError{Code: ErrorCodeInvalidResponse} + // Auxiliary Guard fields, such as Refusal, do not affect audit decisions. } } switch strings.ToLower(safety) { diff --git a/backend/internal/securityaudit/prompt_qwen3guard_test.go b/backend/internal/securityaudit/prompt_qwen3guard_test.go index 5db035401c..c5ab9944ec 100644 --- a/backend/internal/securityaudit/prompt_qwen3guard_test.go +++ b/backend/internal/securityaudit/prompt_qwen3guard_test.go @@ -22,7 +22,7 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) { {"unsafe", "Safety: Unsafe\nCategories: Jailbreak", AllScannerIDs, EventCritical, ActionBlock, false}, {"unknown unsafe", "Safety: Unsafe\nCategories: Future Risk", AllScannerIDs, EventCritical, ActionBlock, false}, {"disabled unsafe warns", "Safety: Unsafe\nCategories: Violent", []string{"PII"}, EventFlag, ActionWarn, false}, - {"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, "", "", true}, + {"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, EventPass, ActionAllow, false}, {"duplicate", "Safety: Safe\nSafety: Safe", AllScannerIDs, "", "", true}, {"duplicate categories", "Safety: Safe\nCategories: None\nCategories: PII", AllScannerIDs, "", "", true}, {"missing categories", "Safety: Safe\n", AllScannerIDs, "", "", true}, @@ -42,6 +42,18 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) { } } +func TestParseQwen3GuardIgnoresAuxiliaryResponseFields(t *testing.T) { + result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: Jailbreak\nRefusal: No", AllScannerIDs) + require.NoError(t, err) + require.Equal(t, "Unsafe", result.Safety) + require.Equal(t, []string{"jailbreak"}, result.Categories) + + serialized, err := json.Marshal(result) + require.NoError(t, err) + require.NotContains(t, string(serialized), "Refusal") + require.NotContains(t, string(serialized), "No") +} + func TestQwen3GuardOfficialCategoriesAliasesAndUnknownAreStable(t *testing.T) { official := "Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, PII, Suicide & Self-Harm, Unethical Acts, Politically Sensitive Topics, Copyright Violation, Jailbreak" result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: "+official, AllScannerIDs) diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 2d9d6605e0..a8cefcd648 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -156,7 +156,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) { return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil } - snapshot, err := ExtractPromptSnapshot(req) + snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly) if errors.Is(err, ErrNoPromptText) { return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil } diff --git a/backend/internal/securityaudit/prompt_service_test.go b/backend/internal/securityaudit/prompt_service_test.go index 5034b31af6..55edf36911 100644 --- a/backend/internal/securityaudit/prompt_service_test.go +++ b/backend/internal/securityaudit/prompt_service_test.go @@ -68,6 +68,25 @@ func TestPromptServiceStartReportsDependencyFailureWithoutPanic(t *testing.T) { require.NoError(t, service.Shutdown(ctx)) } +func TestPromptServiceBlockingLatestTurnOnlyUsesNarrowSnapshot(t *testing.T) { + seen := make([]string, 0, 2) + evaluator := newGuardEvaluator(PromptScannerFunc(func(_ context.Context, _ ActiveEndpoint, chunk string, _ []string) (*NormalizedResult, error) { + seen = append(seen, chunk) + return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil + }), nil, NewAtomicMetrics(), 2, 2) + service := &PromptService{ + config: &fakeConfigStore{active: true, cfg: ActiveConfig{ + RiskControlEnabled: true, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, AllGroups: true, + Scanners: AllScannerIDs, Endpoints: []ActiveEndpoint{{ID: "guard-1", Enabled: true, TimeoutMS: 1000, InputLimit: 4096}}, + }}, + evaluator: evaluator, + } + decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}) + require.NoError(t, err) + require.Equal(t, DecisionAllow, decision.Kind) + require.Equal(t, []string{"latest user input", "previous output"}, seen) +} + func TestPromptServiceRejectsInvalidDeleteConfirmationClaims(t *testing.T) { now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC) start, end := now.Add(-time.Hour), now.Add(time.Hour) diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index 2d38a877ff..b47e846741 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -26,15 +26,30 @@ const promptAuditPrioritySeparator = "\x00SUB2API_PROMPT_AUDIT_PRIORITY_END\x00" type promptSegment struct { text string user bool + role string } func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) { + return extractPromptSnapshot(req, false) +} + +// ExtractBlockingPromptSnapshot builds the narrow, low-latency blocking input +// when configured. Asynchronous auditing always uses ExtractPromptSnapshot so +// the complete client-controlled transcript is retained for review. +func ExtractBlockingPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) { + return extractPromptSnapshot(req, latestTurnOnly) +} + +func extractPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) { var document any if err := json.Unmarshal(req.Body, &document); err != nil { return PromptSnapshot{}, errors.New("prompt audit request JSON is invalid") } extracted := extractProtocolSegments(req.Protocol, document) segments := normalizeSegmentsLatestUserFirst(extracted) + if latestTurnOnly { + segments = blockingSegmentsLatestUserAndPreviousOutput(extracted) + } if len(segments) == 0 { return PromptSnapshot{}, ErrNoPromptText } @@ -138,7 +153,7 @@ func extractMessages(value any, wantedRoles ...string) []promptSegment { } texts := contentTexts(message["content"]) for _, text := range texts { - result = append(result, promptSegment{text: text, user: role == "user"}) + result = append(result, promptSegment{text: text, user: role == "user", role: role}) } } return result @@ -148,7 +163,7 @@ func extractInstructions(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case []any: return systemPromptSegments(contentTexts(typed)) @@ -162,7 +177,7 @@ func extractAnthropicSystem(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case []any: return systemPromptSegments(contentTexts(typed)) @@ -175,13 +190,13 @@ func extractAnthropicSystem(value any) []promptSegment { func extractResponses(value any) []promptSegment { switch typed := value.(type) { case string: - return []promptSegment{{text: typed, user: true}} + return []promptSegment{{text: typed, user: true, role: "user"}} case []any: result := make([]promptSegment, 0, len(typed)) for _, item := range typed { switch entry := item.(type) { case string: - result = append(result, promptSegment{text: entry, user: true}) + result = append(result, promptSegment{text: entry, user: true, role: "user"}) case map[string]any: role := strings.ToLower(stringValue(entry["role"])) if role != "" && !isClientInstructionRole(role) { @@ -189,10 +204,10 @@ func extractResponses(value any) []promptSegment { } if content, exists := entry["content"]; exists { for _, text := range contentTexts(content) { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } else if text := stringValue(entry["text"]); text != "" { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } } @@ -241,7 +256,7 @@ func extractGemini(value any) []promptSegment { for _, part := range parts { if object, ok := part.(map[string]any); ok { if text := stringValue(object["text"]); text != "" { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } } @@ -278,7 +293,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case map[string]any: if parts, ok := typed["parts"].([]any); ok { @@ -286,7 +301,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { for _, part := range parts { if object, ok := part.(map[string]any); ok { if text := stringValue(object["text"]); text != "" { - result = append(result, promptSegment{text: text}) + result = append(result, promptSegment{text: text, role: "system"}) } } } @@ -297,6 +312,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { segments := extractGemini(typed) for index := range segments { segments[index].user = false + segments[index].role = "system" } return segments } @@ -312,7 +328,7 @@ func extractGeminiInstances(value any) []promptSegment { for _, item := range instances { if instance, ok := item.(map[string]any); ok { if prompt := stringValue(instance["prompt"]); prompt != "" { - result = append(result, promptSegment{text: prompt, user: true}) + result = append(result, promptSegment{text: prompt, user: true, role: "user"}) } } } @@ -403,7 +419,7 @@ func contentTexts(value any) []string { continue } typeName := strings.ToLower(stringValue(object["type"])) - if typeName != "" && typeName != "text" && typeName != "input_text" { + if typeName != "" && typeName != "text" && typeName != "input_text" && typeName != "output_text" { continue } if text := stringValue(object["text"]); text != "" { @@ -420,19 +436,13 @@ func contentTexts(value any) []string { } func normalizeSegmentsLatestUserFirst(values []promptSegment) []string { - normalized := make([]promptSegment, 0, len(values)) - for _, value := range values { - value.text = strings.TrimSpace(value.text) - if value.text != "" { - normalized = append(normalized, value) - } - } + normalized := normalizedPromptSegments(values) if len(normalized) == 0 { return nil } priorityIndex := len(normalized) - 1 for index := len(normalized) - 1; index >= 0; index-- { - if normalized[index].user { + if isUserSegment(normalized[index]) { priorityIndex = index break } @@ -447,6 +457,85 @@ func normalizeSegmentsLatestUserFirst(values []promptSegment) []string { return result } +// blockingSegmentsLatestUserAndPreviousOutput limits synchronous guard input to +// the current user turn and the nearest preceding assistant/model turn. It is +// deliberately opt-in because full transcript scanning remains stronger at +// finding client-controlled content placed in older or non-user messages. +func blockingSegmentsLatestUserAndPreviousOutput(values []promptSegment) []string { + normalized := normalizedPromptSegments(values) + latestUserStart := latestUserSegmentStart(normalized) + if latestUserStart < 0 { + // A request without user content cannot be narrowed safely. Preserve the + // established full-snapshot behavior for unusual protocol payloads. + return normalizeSegmentsLatestUserFirst(values) + } + latestUserEnd := latestUserStart + for latestUserEnd < len(normalized) && isUserSegment(normalized[latestUserEnd]) { + latestUserEnd++ + } + currentUserText := make([]string, 0, latestUserEnd-latestUserStart) + for _, segment := range normalized[latestUserStart:latestUserEnd] { + currentUserText = append(currentUserText, segment.text) + } + // A single client turn may have several text content parts. Keep it in one + // priority segment so every part of the latest input is scanned before the + // prior output begins. + selected := []promptSegment{{text: strings.Join(currentUserText, "\n\n"), user: true, role: "user"}} + for index := latestUserStart - 1; index >= 0; index-- { + if !isAssistantOutputSegment(normalized[index]) { + continue + } + start := index + for start > 0 && isAssistantOutputSegment(normalized[start-1]) { + start-- + } + selected = append(selected, normalized[start:index+1]...) + break + } + return promptSegmentTexts(selected) +} + +func normalizedPromptSegments(values []promptSegment) []promptSegment { + normalized := make([]promptSegment, 0, len(values)) + for _, value := range values { + value.text = strings.TrimSpace(value.text) + if value.text != "" { + normalized = append(normalized, value) + } + } + return normalized +} + +func latestUserSegmentStart(values []promptSegment) int { + latest := -1 + for index := len(values) - 1; index >= 0; index-- { + if isUserSegment(values[index]) { + latest = index + break + } + } + for latest > 0 && isUserSegment(values[latest-1]) { + latest-- + } + return latest +} + +func isUserSegment(segment promptSegment) bool { + return segment.user || segment.role == "user" +} + +func isAssistantOutputSegment(segment promptSegment) bool { + return segment.role == "assistant" || segment.role == "model" +} + +func promptSegmentTexts(values []promptSegment) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, value.text) + } + return result +} + func buildPrioritizedScanText(segments []string) (scanText string, metadataText string) { metadataText = strings.Join(segments, "\n\n") if len(segments) <= 1 { @@ -458,7 +547,7 @@ func buildPrioritizedScanText(segments []string) (scanText string, metadataText func promptSegmentsForRole(texts []string, role string) []promptSegment { result := make([]promptSegment, 0, len(texts)) for _, text := range texts { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } return result } diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index ceff3756e2..70d26f925c 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -293,6 +293,97 @@ func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) { } } +func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testing.T) { + tests := []struct { + name, protocol, body, want string + omitted []string + }{ + { + name: "chat keeps multipart latest user and prior assistant", + protocol: "openai_chat_completions", + body: `{"messages":[ + {"role":"system","content":"system instruction"}, + {"role":"user","content":"older user input"}, + {"role":"assistant","content":"older assistant output"}, + {"role":"tool","content":"tool payload"}, + {"role":"assistant","content":"previous assistant output"}, + {"role":"user","content":[{"type":"text","text":"latest user first part"},{"type":"text","text":"latest user second part"}]} + ]}`, + want: "latest user first part\n\nlatest user second part" + promptAuditPrioritySeparator + "previous assistant output", + omitted: []string{"system instruction", "older user input", "older assistant output", "tool payload"}, + }, + { + name: "gemini keeps prior model output", + protocol: "gemini", + body: `{"systemInstruction":{"parts":[{"text":"system instruction"}]},"contents":[ + {"role":"user","parts":[{"text":"older user input"}]}, + {"role":"model","parts":[{"text":"previous model output"}]}, + {"role":"user","parts":[{"text":"latest user input"}]} + ]}`, + want: "latest user input" + promptAuditPrioritySeparator + "previous model output", + omitted: []string{"system instruction", "older user input"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}, true) + require.NoError(t, err) + require.Equal(t, tt.want, snapshot.ScanText) + for _, omitted := range tt.omitted { + require.NotContains(t, snapshot.ScanText, omitted) + } + }) + } +} + +func TestContentTextsIncludesSupportedTextTypes(t *testing.T) { + value := []any{ + map[string]any{"type": "text", "text": "plain text"}, + map[string]any{"type": "input_text", "text": "input text"}, + map[string]any{"type": "output_text", "text": "output text"}, + map[string]any{"type": "image_url", "text": "ignored text"}, + } + + require.Equal(t, []string{"plain text", "input text", "output text"}, contentTexts(value)) +} + +func TestResponsesOutputTextIncludedInFullAndLatestTurnSnapshots(t *testing.T) { + body := []byte(`{"input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"earlier user input"}]}, + {"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","annotations":[],"text":"captured previous assistant output"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"captured latest user input"}]} + ]}`) + + req := Request{Protocol: "openai_responses", Body: body} + full, err := ExtractPromptSnapshot(req) + require.NoError(t, err) + require.Contains(t, full.ScanText, "captured previous assistant output") + require.Contains(t, full.FullPrompt, "captured previous assistant output") + require.Equal(t, 3, full.MessageCount) + + latestTurn, err := ExtractBlockingPromptSnapshot(req, true) + require.NoError(t, err) + require.Equal(t, "captured latest user input"+promptAuditPrioritySeparator+"captured previous assistant output", latestTurn.ScanText) + require.Equal(t, 2, latestTurn.MessageCount) + require.NotContains(t, latestTurn.ScanText, "earlier user input") +} + +func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) { + req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)} + full, err := ExtractPromptSnapshot(req) + require.NoError(t, err) + defaultBlocking, err := ExtractBlockingPromptSnapshot(req, false) + require.NoError(t, err) + require.Equal(t, full, defaultBlocking) + + noUser := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"assistant","content":"assistant output"}]}`)} + fullWithoutUser, err := ExtractPromptSnapshot(noUser) + require.NoError(t, err) + narrowWithoutUser, err := ExtractBlockingPromptSnapshot(noUser, true) + require.NoError(t, err) + require.Equal(t, fullWithoutUser, narrowWithoutUser) +} + func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) { prompt := strings.Repeat("机密业务提示词内容", 40) preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f242cae9d4..9ac40fb556 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -314,19 +314,23 @@ func TestAPIContracts(t *testing.T) { name: "GET /api/v1/groups/available", setup: func(t *testing.T, deps *contractDeps) { t.Helper() - // 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count)。 + // 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count), + // 也不得包含利润控制配置——它与同响应的 rate_multiplier 相乘即可反推上游成本上限。 deps.groupRepo.SetActive([]service.Group{ { - ID: 10, - Name: "Group One", - Description: "desc", - Platform: service.PlatformAnthropic, - RateMultiplier: 1.5, - PeakRateMultiplier: 1.0, - IsExclusive: false, - Status: service.StatusActive, - SubscriptionType: service.SubscriptionTypeStandard, - ModelRoutingEnabled: true, + ID: 10, + Name: "Group One", + Description: "desc", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.5, + PeakRateMultiplier: 1.0, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + ProfitControlEnabled: true, + ProfitMinMargin: 0.3, + ProfitSafetyBuffer: 0.05, + ModelRoutingEnabled: true, ModelRouting: map[string][]int64{ "claude-3-*": []int64{101, 102}, }, @@ -367,6 +371,10 @@ func TestAPIContracts(t *testing.T) { "video_price_720p": null, "video_price_1080p": null, "web_search_price_per_call": null, + "search_price_per_1k": null, + "audio_tts_price_per_million_chars": null, + "audio_stt_price_per_hour": null, + "audio_realtime_price_per_min": null, "allow_image_generation": false, "allow_batch_image_generation": false, "batch_image_discount_multiplier": 0, @@ -706,6 +714,7 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "registration_email_suffix_whitelist": [], + "registration_email_domain_quota_enabled": false, "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", @@ -737,6 +746,18 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, + "tencent_captcha_enabled": false, + "tencent_captcha_app_id": "", + "tencent_captcha_app_secret_key_configured": false, + "tencent_captcha_cloud_secret_id_configured": false, + "tencent_captcha_cloud_secret_key_configured": false, + "tencent_captcha_region": "cn", + "aliyun_captcha_enabled": false, + "aliyun_captcha_access_key_id": "", + "aliyun_captcha_access_key_secret_configured": false, + "aliyun_captcha_scene_id": "", + "aliyun_captcha_prefix": "", + "aliyun_captcha_region": "cn", "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, @@ -864,6 +885,9 @@ 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_base_url_mode": "cli", + "grok_cross_client_model_map_enabled": true, "purchase_subscription_enabled": false, "purchase_subscription_url": "", "table_default_page_size": 20, @@ -874,6 +898,7 @@ func TestAPIContracts(t *testing.T) { "max_codex_version": "", "codex_cli_only_blacklist": "", "codex_cli_only_whitelist": "", + "compact_home_enabled": false, "codex_cli_only_allow_app_server_clients": false, "codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]", "allow_ungrouped_key_scheduling": false, @@ -921,6 +946,9 @@ func TestAPIContracts(t *testing.T) { "openai_advanced_scheduler_effective_weight_previous_response": "5", "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", + "openai_codex_client_version": "", + "openai_codex_client_version_synced": "", + "openai_codex_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, @@ -951,11 +979,14 @@ func TestAPIContracts(t *testing.T) { "payment_alipay_mobile_precreate_deep_link": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, + "account_scheduling_thresholds": {"anthropic":100,"grok":100,"openai":100}, "subscription_expiry_notify_enabled": true, "balance_low_notify_threshold": 0, "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], "channel_monitor_enabled": true, + "channel_monitor_mode": "v1", + "channel_monitor_hide_throughput": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1029,6 +1060,7 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "registration_email_suffix_whitelist": [], + "registration_email_domain_quota_enabled": false, "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", @@ -1061,6 +1093,18 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": false, "turnstile_site_key": "", "turnstile_secret_key_configured": false, + "tencent_captcha_enabled": false, + "tencent_captcha_app_id": "", + "tencent_captcha_app_secret_key_configured": false, + "tencent_captcha_cloud_secret_id_configured": false, + "tencent_captcha_cloud_secret_key_configured": false, + "tencent_captcha_region": "cn", + "aliyun_captcha_enabled": false, + "aliyun_captcha_access_key_id": "", + "aliyun_captcha_access_key_secret_configured": false, + "aliyun_captcha_scene_id": "", + "aliyun_captcha_prefix": "", + "aliyun_captcha_region": "cn", "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, @@ -1123,6 +1167,9 @@ func TestAPIContracts(t *testing.T) { "doc_url": "", "home_content": "", "hide_ccs_import_button": false, + "grok_default_text_model": "grok-4.5", + "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, @@ -1175,6 +1222,7 @@ func TestAPIContracts(t *testing.T) { "max_codex_version": "", "codex_cli_only_blacklist": "", "codex_cli_only_whitelist": "", + "compact_home_enabled": false, "codex_cli_only_allow_app_server_clients": false, "codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]", "web_search_emulation_enabled": false, @@ -1210,6 +1258,9 @@ func TestAPIContracts(t *testing.T) { "openai_advanced_scheduler_effective_weight_previous_response": "5", "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", + "openai_codex_client_version": "", + "openai_codex_client_version_synced": "", + "openai_codex_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, @@ -1238,11 +1289,14 @@ func TestAPIContracts(t *testing.T) { "payment_alipay_mobile_precreate_deep_link": false, "balance_low_notify_enabled": false, "account_quota_notify_enabled": false, + "account_scheduling_thresholds": {"anthropic":100,"grok":100,"openai":100}, "subscription_expiry_notify_enabled": true, "balance_low_notify_threshold": 0, "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], "channel_monitor_enabled": true, + "channel_monitor_mode": "v1", + "channel_monitor_hide_throughput": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1817,6 +1871,16 @@ func (s *stubAccountRepo) Update(ctx context.Context, account *service.Account) return errors.New("not implemented") } +func (s *stubAccountRepo) UpdateWithAccountBillingSettings( + ctx context.Context, + account *service.Account, + probeEnabled *bool, + rateSyncEnabled *bool, + rateMultiplier *float64, +) error { + return errors.New("not implemented") +} + func (s *stubAccountRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } @@ -2147,6 +2211,9 @@ func (stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSub func (stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } +func (stubUserSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} func (stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -2198,10 +2265,10 @@ func (stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID func (stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error { return errors.New("not implemented") } -func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return errors.New("not implemented") } -func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error { +func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { return errors.New("not implemented") } func (stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error { diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 69c226660b..6b5f10d48e 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -82,7 +82,7 @@ type fakeGoogleSubscriptionRepo struct { getByID func(ctx context.Context, id int64) (*service.UserSubscription, error) getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) updateStatus func(ctx context.Context, subscriptionID int64, status string) error - activateWindow func(ctx context.Context, id int64, start time.Time) error + activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error resetDaily func(ctx context.Context, id int64, start time.Time) error resetWeekly func(ctx context.Context, id int64, start time.Time) error resetMonthly func(ctx context.Context, id int64, start time.Time) error @@ -176,6 +176,10 @@ func (f fakeGoogleSubscriptionRepo) GetByID(ctx context.Context, id int64) (*ser } return nil, errors.New("not implemented") } + +func (f fakeGoogleSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + return f.GetByID(ctx, id) +} func (f fakeGoogleSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -227,13 +231,13 @@ func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscripti func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error { return errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { if f.activateWindow != nil { - return f.activateWindow(ctx, id, start) + return f.activateWindow(ctx, id, dailyStart, periodicStart) } return errors.New("not implemented") } -func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return errors.New("not implemented") } func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error { @@ -882,7 +886,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil }, diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 28bc3ac0f7..964fe8041b 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -119,7 +119,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { sub.DailyWindowStart = &start sub.DailyUsageUSD = 0 @@ -252,7 +252,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { return &clone, nil }, updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, - activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil }, resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil }, resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil }, @@ -1631,7 +1631,7 @@ type stubUserSubscriptionRepo struct { getByID func(ctx context.Context, id int64) (*service.UserSubscription, error) getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) updateStatus func(ctx context.Context, subscriptionID int64, status string) error - activateWindow func(ctx context.Context, id int64, start time.Time) error + activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error resetDaily func(ctx context.Context, id int64, start time.Time) error resetWeekly func(ctx context.Context, id int64, start time.Time) error resetMonthly func(ctx context.Context, id int64, start time.Time) error @@ -1683,6 +1683,10 @@ func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*serv return nil, errors.New("not implemented") } +func (r *stubUserSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + return r.GetByID(ctx, id) +} + func (r *stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -1749,14 +1753,14 @@ func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscription return errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { +func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { if r.activateWindow != nil { - return r.activateWindow(ctx, id, start) + return r.activateWindow(ctx, id, dailyStart, periodicStart) } return errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return errors.New("not implemented") } diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index e71f6318d4..bb5d99abe2 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -18,6 +18,25 @@ const ( NonceTemplate = "__CSP_NONCE__" // CloudflareInsightsDomain is the domain for Cloudflare Web Analytics CloudflareInsightsDomain = "https://static.cloudflareinsights.com" + // TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain (Chinese mainland site). + TencentCaptchaDomain = "https://turing.captcha.qcloud.com" + // TencentCaptchaStaticDomain is the Tencent Captcha static asset domain. + TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com" + // TencentCaptchaCDNDomain 是天御国内站的核心 JS CDN 主机: + // 入口脚本 TJCaptcha.js 会再从这里加载 /1/tgJCap.*.js,缺失时会被 script-src 拦截。 + TencentCaptchaCDNDomain = "https://turing.captcha.gtimg.com" + // TencentCaptchaGlobalDomain 是天御国际站的 Web SDK 与验证弹窗 iframe 主机。 + TencentCaptchaGlobalDomain = "https://ca.turing.captcha.qcloud.com" + // TencentCaptchaGlobalCDNDomain 是天御国际站的核心 JS CDN 主机。 + TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com" + // TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。 + TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com" + // TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。 + TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com" + // TencentCaptchaRceDomain 是国际站风控校验接口主机。 + TencentCaptchaRceDomain = "https://rce.tencentrio.com" + // TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。 + TencentCaptchaWorkerSource = "blob:" // StripeDomain is the domain for Stripe.js SDK StripeDomain = "https://*.stripe.com" // AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。 @@ -35,6 +54,20 @@ var requiredCSPDirectiveValues = []struct { value string }{ {"script-src", CloudflareInsightsDomain}, + {"script-src", TencentCaptchaDomain}, + {"frame-src", TencentCaptchaDomain}, + {"style-src", TencentCaptchaStaticDomain}, + {"script-src", TencentCaptchaCDNDomain}, + {"script-src", TencentCaptchaGlobalDomain}, + {"script-src", TencentCaptchaGlobalCDNDomain}, + {"script-src", TencentCaptchaPrehandleDomain}, + {"script-src", TencentCaptchaJQueryDomain}, + {"connect-src", TencentCaptchaDomain}, + {"connect-src", TencentCaptchaPrehandleDomain}, + {"connect-src", TencentCaptchaRceDomain}, + {"frame-src", TencentCaptchaGlobalDomain}, + {"frame-src", TencentCaptchaPrehandleDomain}, + {"worker-src", TencentCaptchaWorkerSource}, {"script-src", StripeDomain}, {"frame-src", StripeDomain}, {"script-src", AirwallexStaticDomain}, @@ -127,8 +160,8 @@ func isAPIRoutePath(c *gin.Context) bool { strings.HasPrefix(path, "/images") } -// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。 -// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。 +// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和运行时组件必需域名。 +// 这样旧配置文件没有及时补域名时,验证码和支付组件仍能正常加载。 func enhanceCSPPolicy(policy string) string { // Add nonce placeholder to script-src if not present if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") { diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index c980f7e6e4..cb355e0c4d 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -129,6 +129,7 @@ func TestSecurityHeaders(t *testing.T) { assert.Contains(t, csp, "default-src 'self'") assert.Contains(t, csp, "'nonce-") assert.Contains(t, csp, CloudflareInsightsDomain) + assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource)) }) t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) { @@ -192,6 +193,7 @@ func TestSecurityHeaders(t *testing.T) { assert.NotEmpty(t, csp) // Default policy should contain these elements assert.Contains(t, csp, "default-src 'self'") + assert.Contains(t, csp, TencentCaptchaDomain) }) t.Run("uses_default_policy_when_whitespace_only", func(t *testing.T) { @@ -313,6 +315,45 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Equal(t, 1, count) }) + t.Run("adds_tencent_captcha_domain_for_web_sdk", func(t *testing.T) { + policy := "default-src 'self'; script-src 'self' __CSP_NONCE__" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain)) + assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com") + + // 入口脚本会再从 CDN 拉核心 JS,国际站还会换用 ca./global. 两个主机; + // 缺任意一个都会让天御 SDK 触发 script-src 拦截。 + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaCDNDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain)) + assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource)) + }) + + t.Run("does_not_duplicate_tencent_captcha_worker_source", func(t *testing.T) { + policy := "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource)) + }) + + t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) { + // 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户 + for _, required := range requiredCSPDirectiveValues { + assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value), + "DefaultCSPPolicy 缺少 %s %s", required.directive, required.value) + } + }) + t.Run("handles_policy_without_script_src", func(t *testing.T) { policy := "default-src 'self'" enhanced := enhanceCSPPolicy(policy) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 981addecb9..149d4f95bc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -3,6 +3,7 @@ package routes import ( "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -106,7 +107,8 @@ func RegisterAdminRoutes( registerChannelRoutes(admin, h) // 渠道监控 - registerChannelMonitorRoutes(admin, h) + registerChannelMonitorRoutes(admin, h, settingService) + registerChannelMonitorV2Routes(admin, h, settingService) // 风控中心 registerContentModerationRoutes(admin, h) @@ -379,6 +381,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/:id/revert-proxy-fallback", h.Admin.Account.RevertProxyFallback) accounts.GET("/:id/usage", h.Admin.Account.GetUsage) accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats) + accounts.POST("/usage/batch", h.Admin.Account.GetBatchUsage) accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats) accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit) accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota) @@ -395,6 +398,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) + accounts.POST("/batch-delete", h.Admin.Account.BatchDelete) accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError) accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh) @@ -436,6 +440,7 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth) openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT) openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) + openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) } } @@ -461,9 +466,12 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { grok := admin.Group("/grok") { + grok.GET("/oauth/capabilities", h.Admin.GrokOAuth.GetCapabilities) grok.POST("/oauth/auth-url", h.Admin.GrokOAuth.GenerateAuthURL) grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode) grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken) + grok.POST("/oauth/sso-token", h.Admin.GrokOAuth.ValidateSSOToken) + grok.POST("/oauth/password", h.Admin.GrokOAuth.AuthorizePassword) grok.POST("/oauth/create-from-oauth", h.Admin.GrokOAuth.CreateAccountFromOAuth) grok.POST("/sso-to-oauth", h.Admin.GrokOAuth.CreateAccountsFromSSO) grok.POST("/oauth/reconcile", h.Admin.GrokOAuth.ReconcileOAuthAccounts) @@ -727,8 +735,10 @@ func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) { + guard := channelMonitorAdminFeatureGuard(settingService) monitors := admin.Group("/channel-monitors") + monitors.Use(guard) { monitors.GET("", h.Admin.ChannelMonitor.List) monitors.POST("", h.Admin.ChannelMonitor.Create) @@ -741,6 +751,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } templates := admin.Group("/channel-monitor-templates") + templates.Use(guard) { templates.GET("", h.Admin.ChannelMonitorTemplate.List) templates.POST("", h.Admin.ChannelMonitorTemplate.Create) @@ -771,3 +782,64 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } } + +func registerChannelMonitorV2Routes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) { + // Config GET/PUT: feature enabled only (operators can prepare V2 before flipping mode). + // Read/matrix endpoints: require mode=v2 so V1 deployments do not serve passive data. + featureGuard := channelMonitorAdminFeatureGuard(settingService) + modeV2Guard := channelMonitorModeV2Guard(settingService) + + monitor := admin.Group("/channel-monitor-v2") + { + config := monitor.Group("") + config.Use(featureGuard) + { + config.GET("/config", h.ChannelMonitorV2.GetConfig) + config.PUT("/config", h.ChannelMonitorV2.UpdateConfig) + } + reads := monitor.Group("") + reads.Use(modeV2Guard) + { + reads.GET("/dimensions", h.ChannelMonitorV2.Dimensions) + reads.GET("/snapshot", h.ChannelMonitorV2.AdminSnapshot) + reads.GET("/models", h.ChannelMonitorV2.AdminModels) + reads.GET("/matrix", h.ChannelMonitorV2.AdminMatrix) + reads.GET("/errors", h.ChannelMonitorV2.Errors) + reads.GET("/users", h.ChannelMonitorV2.AdminUsers) + } + } +} + +func channelMonitorAdminFeatureGuard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService != nil && settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled { + c.Next() + return + } + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + } +} + +// channelMonitorModeV2Guard requires feature enabled and channel_monitor_mode=v2. +func channelMonitorModeV2Guard(settingService *service.SettingService) gin.HandlerFunc { + return func(c *gin.Context) { + if settingService == nil { + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + return + } + rt := settingService.GetChannelMonitorRuntime(c.Request.Context()) + if !rt.Enabled { + response.ErrorFrom(c, service.ErrChannelMonitorDisabled) + c.Abort() + return + } + if !rt.PassiveAggregationAllowed() { + response.ErrorFrom(c, service.ErrChannelMonitorModeMismatch) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 2b4b9bd31d..18f39ed314 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -73,7 +73,13 @@ func RegisterAuthRoutes( FailureMode: middleware.RateLimitFailClose, }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) + auth.POST("/oauth/linuxdo/start", rateLimiter.LimitWithOptions("oauth-linuxdo-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.LinuxDoOAuthStart) auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart) + auth.POST("/oauth/github/start", rateLimiter.LimitWithOptions("oauth-github-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.GitHubOAuthStart) auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback) auth.POST("/oauth/github/complete-registration", rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{ @@ -82,6 +88,9 @@ func RegisterAuthRoutes( h.Auth.CompleteGitHubOAuthRegistration, ) auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart) + auth.POST("/oauth/google/start", rateLimiter.LimitWithOptions("oauth-google-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.GoogleOAuthStart) auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback) auth.POST("/oauth/google/complete-registration", rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{ @@ -97,6 +106,9 @@ func RegisterAuthRoutes( }) auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart) + auth.POST("/oauth/wechat/start", rateLimiter.LimitWithOptions("oauth-wechat-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.WeChatOAuthStart) auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") @@ -167,6 +179,9 @@ func RegisterAuthRoutes( h.Auth.CreateWeChatOAuthAccount, ) auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart) + auth.POST("/oauth/oidc/start", rateLimiter.LimitWithOptions("oauth-oidc-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.OIDCOAuthStart) auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") @@ -193,6 +208,9 @@ func RegisterAuthRoutes( h.Auth.CreateOIDCOAuthAccount, ) auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart) + auth.POST("/oauth/dingtalk/start", rateLimiter.LimitWithOptions("oauth-dingtalk-start", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.DingTalkOAuthStart) auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") diff --git a/backend/internal/server/routes/channel_monitor_feature_gate_test.go b/backend/internal/server/routes/channel_monitor_feature_gate_test.go new file mode 100644 index 0000000000..1881daa4e5 --- /dev/null +++ b/backend/internal/server/routes/channel_monitor_feature_gate_test.go @@ -0,0 +1,173 @@ +package routes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// channelMonitorRouteSettingRepoStub is a minimal SettingRepository for route guards. +type channelMonitorRouteSettingRepoStub struct { + values map[string]string +} + +func (s *channelMonitorRouteSettingRepoStub) Get(context.Context, string) (*service.Setting, error) { + panic("unexpected Get call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + return s.values[key], nil +} + +func (s *channelMonitorRouteSettingRepoStub) Set(context.Context, string, string) error { + panic("unexpected Set call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (s *channelMonitorRouteSettingRepoStub) SetMultiple(context.Context, map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *channelMonitorRouteSettingRepoStub) GetAll(context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *channelMonitorRouteSettingRepoStub) Delete(context.Context, string) error { + panic("unexpected Delete call") +} + +func newChannelMonitorRouteSettings(enabled bool) *service.SettingService { + value := "false" + if enabled { + value = "true" + } + return service.NewSettingService(&channelMonitorRouteSettingRepoStub{ + values: map[string]string{ + service.SettingKeyChannelMonitorEnabled: value, + }, + }, &config.Config{}) +} + +func newChannelMonitorModeSettings(enabled bool, mode string) *service.SettingService { + enabledVal := "false" + if enabled { + enabledVal = "true" + } + return service.NewSettingService(&channelMonitorRouteSettingRepoStub{ + values: map[string]string{ + service.SettingKeyChannelMonitorEnabled: enabledVal, + service.SettingKeyChannelMonitorMode: mode, + }, + }, &config.Config{}) +} + +func TestChannelMonitorAdminFeatureGuard(t *testing.T) { + tests := []struct { + name string + svc *service.SettingService + wantStatus int + }{ + { + name: "nil setting service blocks", + svc: nil, + wantStatus: http.StatusForbidden, + }, + { + name: "disabled blocks", + svc: newChannelMonitorRouteSettings(false), + wantStatus: http.StatusForbidden, + }, + { + name: "enabled allows", + svc: newChannelMonitorRouteSettings(true), + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(channelMonitorAdminFeatureGuard(tt.svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, tt.wantStatus, rec.Code) + if tt.wantStatus == http.StatusForbidden { + require.Contains(t, rec.Body.String(), "CHANNEL_MONITOR_DISABLED") + } + }) + } +} + +func TestChannelMonitorModeV2Guard(t *testing.T) { + tests := []struct { + name string + svc *service.SettingService + wantStatus int + wantCode string + }{ + { + name: "nil blocks as disabled", + svc: nil, + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_DISABLED", + }, + { + name: "feature off blocks", + svc: newChannelMonitorModeSettings(false, service.ChannelMonitorModeV2), + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_DISABLED", + }, + { + name: "mode v1 blocks with mode mismatch", + svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV1), + wantStatus: http.StatusForbidden, + wantCode: "CHANNEL_MONITOR_MODE_MISMATCH", + }, + { + name: "mode v2 allows", + svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV2), + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(channelMonitorModeV2Guard(tt.svc)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + router.ServeHTTP(rec, req) + require.Equal(t, tt.wantStatus, rec.Code) + if tt.wantCode != "" { + require.Contains(t, rec.Body.String(), tt.wantCode) + } + }) + } +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index b059646fad..8d5fcd6a53 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -153,6 +153,25 @@ func RegisterGatewayRoutes( service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}}) } + // /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉 + // 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见 + // service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。 + guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + if !service.IsForwardableOpenAIResponsesRequestPath(c) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Unsupported responses subpath", + }, + }) + return + } + next(c) + } + } + // API网关(Claude API兼容) gateway := r.Group("/v1") gateway.Use(bodyLimit) @@ -190,13 +209,13 @@ func RegisterGatewayRoutes( } h.Gateway.Responses(c) }) - gateway.POST("/responses/*subpath", func(c *gin.Context) { + gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) { if isOpenAIResponsesCompatibleGatewayPlatform(c) { h.OpenAIGateway.Responses(c) return } h.Gateway.Responses(c) - }) + })) gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) gateway.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) @@ -237,11 +256,65 @@ func RegisterGatewayRoutes( gateway.POST("/images/batches/:id/cancel", h.BatchImage.Cancel) gateway.DELETE("/images/batches/:id", h.BatchImage.DeleteRecord) gateway.DELETE("/images/batches/:id/outputs", h.BatchImage.DeleteOutputs) + // OpenAI-compatible clients may create through /videos; xAI receives the + // canonical /videos/generations route inside the Grok media forwarder. + gateway.POST("/videos", videoGenerationHandler) gateway.POST("/videos/generations", videoGenerationHandler) gateway.POST("/videos/edits", videoEditHandler) gateway.POST("/videos/extensions", videoExtensionHandler) + gateway.GET("/videos/generations/:request_id/content", videoContentHandler) + gateway.GET("/videos/edits/:request_id/content", videoContentHandler) + gateway.GET("/videos/extensions/:request_id/content", videoContentHandler) + gateway.GET("/videos/generations/:request_id", videoStatusHandler) + gateway.GET("/videos/edits/:request_id", videoStatusHandler) + gateway.GET("/videos/extensions/:request_id", videoStatusHandler) gateway.GET("/videos/:request_id", videoStatusHandler) gateway.GET("/videos/:request_id/content", videoContentHandler) + + // xAI Voice APIs (Grok platform only): HTTP TTS/STT + Realtime WS. + // Not part of the creation-center product surface — gateway relay only. + voiceHandler := func(endpoint string) gin.HandlerFunc { + return func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokVoice(c, endpoint) + } + } + gateway.POST("/tts", voiceHandler("tts")) + gateway.POST("/stt", voiceHandler("stt")) + gateway.POST("/custom-voices", voiceHandler("custom-voices")) + customVoicePathHandler := func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c)) + } + gateway.GET("/custom-voices", voiceHandler("custom-voices")) + gateway.GET("/custom-voices/:voice_id/audio", customVoicePathHandler) + gateway.GET("/custom-voices/:voice_id", customVoicePathHandler) + gateway.PATCH("/custom-voices/:voice_id", customVoicePathHandler) + gateway.DELETE("/custom-voices/:voice_id", customVoicePathHandler) + gateway.GET("/realtime", func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokRealtime(c) + }) + gateway.POST("/web_search", func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}}) + return + } + h.Gateway.WebSearch(c) + }) } // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) @@ -269,7 +342,7 @@ func RegisterGatewayRoutes( h.Gateway.Responses(c) } r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler) - r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler) + r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler)) r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch) r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) @@ -282,7 +355,7 @@ func RegisterGatewayRoutes( codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live) codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband) codexDirect.POST("/responses", responsesHandler) - codexDirect.POST("/responses/*subpath", responsesHandler) + codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler)) codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) @@ -315,12 +388,62 @@ func RegisterGatewayRoutes( r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get) + r.POST("/videos", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler) r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler) + r.GET("/videos/generations/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/edits/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/extensions/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + r.GET("/videos/generations/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) + r.GET("/videos/edits/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) + r.GET("/videos/extensions/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + rootVoiceHandler := func(endpoint string) gin.HandlerFunc { + return func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokVoice(c, endpoint) + } + } + r.POST("/tts", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("tts")) + r.POST("/stt", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("stt")) + r.POST("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices")) + rootCustomVoicePathHandler := func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c)) + } + r.GET("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices")) + r.GET("/custom-voices/:voice_id/audio", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) + r.GET("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) + r.PATCH("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) + r.DELETE("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) + r.GET("/realtime", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}}) + return + } + h.OpenAIGateway.GrokRealtime(c) + }) + r.POST("/web_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformGrok { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}}) + return + } + h.Gateway.WebSearch(c) + }) + // Antigravity 模型列表 r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels) @@ -484,6 +607,22 @@ func compositeGeminiTargetPlatformMiddleware(resolver *service.CompositeRouteRes } } +// grokCustomVoiceEndpoint derives the upstream Voice endpoint for the +// /custom-voices/:voice_id[/audio] routes. +// +// The /audio suffix must be decided from the matched route template, not from +// the raw URL path: a voice literally named "audio" makes GET +// /custom-voices/audio match /custom-voices/:voice_id, and a raw-path suffix +// check would rewrite it to custom-voices/audio/audio — turning a profile +// lookup into an audio download. +func grokCustomVoiceEndpoint(c *gin.Context) string { + endpoint := "custom-voices/" + c.Param("voice_id") + if strings.HasSuffix(c.FullPath(), "/:voice_id/audio") { + endpoint += "/audio" + } + return endpoint +} + func compositeGeminiModelFromParams(c *gin.Context) string { if c == nil { return "" diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 195351f9e7..ded7bd2989 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -152,6 +152,8 @@ func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { "/images/generations", "/images/edits", "/v1/videos/generations", + "/v1/videos", + "/videos", "/videos/generations", "/v1/videos/edits", "/videos/edits", @@ -170,8 +172,20 @@ func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { for _, path := range []string{ "/v1/videos/request-123", "/videos/request-123", + "/v1/videos/generations/request-123", + "/videos/generations/request-123", + "/v1/videos/edits/request-123", + "/videos/edits/request-123", + "/v1/videos/extensions/request-123", + "/videos/extensions/request-123", "/v1/videos/request-123/content", "/videos/request-123/content", + "/v1/videos/generations/request-123/content", + "/videos/generations/request-123/content", + "/v1/videos/edits/request-123/content", + "/videos/edits/request-123/content", + "/v1/videos/extensions/request-123/content", + "/videos/extensions/request-123/content", } { req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() @@ -182,6 +196,62 @@ func TestGatewayRoutesGrokImagesAndVideosPathsAreRegistered(t *testing.T) { } } +func TestGatewayRoutesGrokCustomVoiceCRUDPathsAreRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter(service.PlatformGrok) + registered := make(map[string]bool) + for _, route := range router.Routes() { + registered[route.Method+" "+route.Path] = true + } + for _, route := range []string{ + "POST /v1/custom-voices", + "GET /v1/custom-voices", + "GET /v1/custom-voices/:voice_id", + "PATCH /v1/custom-voices/:voice_id", + "DELETE /v1/custom-voices/:voice_id", + "GET /v1/custom-voices/:voice_id/audio", + "POST /custom-voices", + "GET /custom-voices", + "GET /custom-voices/:voice_id", + "PATCH /custom-voices/:voice_id", + "DELETE /custom-voices/:voice_id", + "GET /custom-voices/:voice_id/audio", + } { + require.True(t, registered[route], "%s should be registered", route) + } +} + +func TestGrokCustomVoiceEndpointUsesRouteTemplateNotRawPath(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + var got string + capture := func(c *gin.Context) { + got = grokCustomVoiceEndpoint(c) + c.Status(http.StatusOK) + } + router.GET("/v1/custom-voices/:voice_id/audio", capture) + router.GET("/v1/custom-voices/:voice_id", capture) + + for _, tc := range []struct { + path string + want string + }{ + {path: "/v1/custom-voices/voice-123", want: "custom-voices/voice-123"}, + {path: "/v1/custom-voices/voice-123/audio", want: "custom-voices/voice-123/audio"}, + // A voice literally named "audio" matches /:voice_id, not /:voice_id/audio. + // A raw-path suffix check would turn this profile lookup into an audio download. + {path: "/v1/custom-voices/audio", want: "custom-voices/audio"}, + {path: "/v1/custom-voices/audio/audio", want: "custom-voices/audio/audio"}, + } { + got = "" + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "path=%s", tc.path) + require.Equal(t, tc.want, got, "path=%s", tc.path) + } +} + func TestGatewayRoutesCompositeVideoLookupsUseGrokHandler(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformComposite) @@ -241,6 +311,8 @@ func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { body string }{ {http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodPost, "/v1/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, + {http.MethodPost, "/videos", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, {http.MethodPost, "/videos/generations", `{"model":"grok-imagine-video-1.5","prompt":"waves"}`}, {http.MethodPost, "/v1/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, {http.MethodPost, "/videos/edits", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, @@ -248,8 +320,20 @@ func TestGatewayRoutesNonGrokVideosAreRejectedAtPlatformGate(t *testing.T) { {http.MethodPost, "/videos/extensions", `{"model":"grok-imagine-video","prompt":"waves","video":{"url":"https://example.com/in.mp4"}}`}, {http.MethodGet, "/v1/videos/request-123", ""}, {http.MethodGet, "/videos/request-123", ""}, + {http.MethodGet, "/v1/videos/generations/request-123", ""}, + {http.MethodGet, "/videos/generations/request-123", ""}, + {http.MethodGet, "/v1/videos/edits/request-123", ""}, + {http.MethodGet, "/videos/edits/request-123", ""}, + {http.MethodGet, "/v1/videos/extensions/request-123", ""}, + {http.MethodGet, "/videos/extensions/request-123", ""}, {http.MethodGet, "/v1/videos/request-123/content", ""}, {http.MethodGet, "/videos/request-123/content", ""}, + {http.MethodGet, "/v1/videos/generations/request-123/content", ""}, + {http.MethodGet, "/videos/generations/request-123/content", ""}, + {http.MethodGet, "/v1/videos/edits/request-123/content", ""}, + {http.MethodGet, "/videos/edits/request-123/content", ""}, + {http.MethodGet, "/v1/videos/extensions/request-123/content", ""}, + {http.MethodGet, "/videos/extensions/request-123/content", ""}, } { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) req.Header.Set("Content-Type", "application/json") @@ -333,6 +417,33 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) { } } +// TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths 端到端锁定不变式: +// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此不合规的子路径必须 +// 在入口就被拒绝,不得进入调度与转发流程。 +func TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths(t *testing.T) { + router := newGatewayRoutesTestRouter() + + for _, path := range []string{ + "/v1/responses/../../x/y", + "/v1/responses/..%2f..%2fx/y", + "/v1/responses/%2e%2e/%2e%2e/x", + "/responses/%2e%2e%2fx", + "/backend-api/codex/responses/..%2f..%2fx", + `/v1/responses/..\..\x`, + "/v1/responses/%3fa=b", + "/v1/responses/x%23frag", + "/v1/responses/compact%2f..", + } { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code, "path=%s must be rejected at the edge", path) + require.Contains(t, w.Body.String(), "Unsupported responses subpath", "path=%s", path) + } +} + func TestGatewayRoutesOpenAICountTokensPathIsRegistered(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformOpenAI) diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index e4591ab4f5..f03cf5b314 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -41,14 +41,19 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) { "/images/generations/async": {"image_task_handler.go"}, "/images/edits/async": {"image_task_handler.go"}, "/images/batches": {"batch_image_handler.go"}, + "/videos": {"grok_media.go"}, "/videos/generations": {"grok_media.go"}, "/videos/edits": {"grok_media.go"}, "/videos/extensions": {"grok_media.go"}, "/models/*modelAction": {"gemini_v1beta_handler.go"}, + "/tts": {"grok_audio.go"}, + "/web_search": {"gateway_web_search.go"}, } excluded := map[string]string{ "/messages/count_tokens": "tokenization only; it does not execute a model request", "/images/batches/:id/cancel": "control-plane cancellation with no user prompt", + "/stt": "speech transcription is not a text-generation prompt", + "/custom-voices": "voice profile management has no model prompt", } unclassified := make([]string, 0) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index bcb2a8c917..65ecaa037d 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -141,5 +141,18 @@ func RegisterUserRoutes( monitors.GET("", h.ChannelMonitor.List) monitors.GET("/:id/status", h.ChannelMonitor.GetStatus) } + + // V2 passive views require feature on + mode=v2. + monitorV2 := authenticated.Group("/channel-monitor-v2") + monitorV2.Use(panelRateLimiter.Heavy()) + monitorV2.Use(channelMonitorModeV2Guard(settingService)) + { + monitorV2.GET("/dimensions", h.ChannelMonitorV2.Dimensions) + monitorV2.GET("/snapshot", h.ChannelMonitorV2.Snapshot) + monitorV2.GET("/models", h.ChannelMonitorV2.Models) + monitorV2.GET("/matrix", h.ChannelMonitorV2.Matrix) + monitorV2.GET("/errors", h.ChannelMonitorV2.Errors) + monitorV2.GET("/users", h.ChannelMonitorV2.Users) + } } } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 314fbaeee4..cb74aaf50f 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -6,6 +6,7 @@ import ( "errors" "hash/fnv" "log/slog" + "net/url" "reflect" "sort" "strconv" @@ -71,6 +72,7 @@ type Account struct { modelMappingCacheRawPtr uintptr modelMappingCacheRawLen int modelMappingCacheRawSig uint64 + modelMappingCacheRuntimeVersion uint64 // header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例) headerOverrideCache map[string]string @@ -553,6 +555,7 @@ func stringMappingFromRaw(raw any) map[string]string { } func (a *Account) GetModelMapping() map[string]string { + runtimeVersion := xai.RuntimeModelMappingVersion() credentialsPtr := mapPtr(a.Credentials) rawMapping, _ := a.Credentials["model_mapping"].(map[string]any) rawPtr := mapPtr(rawMapping) @@ -563,7 +566,8 @@ func (a *Account) GetModelMapping() map[string]string { if a.modelMappingCacheReady && a.modelMappingCacheCredentialsPtr == credentialsPtr && a.modelMappingCacheRawPtr == rawPtr && - a.modelMappingCacheRawLen == rawLen { + a.modelMappingCacheRawLen == rawLen && + a.modelMappingCacheRuntimeVersion == runtimeVersion { rawSig = modelMappingSignature(rawMapping) rawSigReady = true if a.modelMappingCacheRawSig == rawSig { @@ -582,6 +586,7 @@ func (a *Account) GetModelMapping() map[string]string { a.modelMappingCacheRawPtr = rawPtr a.modelMappingCacheRawLen = rawLen a.modelMappingCacheRawSig = rawSig + a.modelMappingCacheRuntimeVersion = runtimeVersion return mapping } @@ -620,6 +625,11 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri "gemini-3-flash", "gemini-3.1-pro-high", "gemini-3.1-pro-low", + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", }) applyAntigravityGemini31ProAliases(result) } @@ -1306,25 +1316,47 @@ func (a *Account) GetOpenAIRefreshToken() string { // traffic (OAuth authorization and token refresh) always uses the official // auth endpoints regardless of this value. func (a *Account) GetGrokBaseURL() string { - if !a.IsGrok() { + if a == nil || !a.IsGrok() { return "" } - baseURL := strings.TrimSpace(a.GetCredential("base_url")) if a.IsGrokOAuth() { - // Operators switch subscription traffic between the official CLI - // gateway, the official/regional API hosts and third-party relays - // (individual endpoints go down from time to time), so a stored - // value is always honored as-is. Only empty or unparseable values - // fall back to the default CLI gateway. - if baseURL == "" || !xai.IsParseableBaseURL(baseURL) { - return xai.DefaultCLIBaseURL + return a.GetGrokBaseURLOr(xai.DefaultCLIBaseURL) + } + return a.GetGrokBaseURLOr(xai.DefaultBaseURL) +} + +// GetGrokBaseURLOr resolves an explicit account endpoint, falling back to the +// supplied default. Official OAuth endpoints are normalized here; custom +// endpoints are retained for the request builder's operator URL policy. +func (a *Account) GetGrokBaseURLOr(defaultBaseURL string) string { + if a == nil || !a.IsGrok() { + return "" + } + defaultBaseURL = strings.TrimRight(strings.TrimSpace(defaultBaseURL), "/") + if defaultBaseURL == "" { + if a.IsGrokOAuth() { + defaultBaseURL = xai.DefaultCLIBaseURL + } else { + defaultBaseURL = xai.DefaultBaseURL } + } + baseURL := strings.TrimSpace(a.GetCredential("base_url")) + if baseURL == "" { + return defaultBaseURL + } + if !a.IsGrokOAuth() { return baseURL } - if baseURL != "" { - return baseURL + // Explicit regional/API or custom values remain pinned. Custom endpoints are checked by the + // operator URL policy at the request builder, which has access to config. + if validated, err := xai.ValidateTrustedBaseURL(baseURL); err == nil { + return validated } - return xai.DefaultBaseURL + if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" && + parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" { + return strings.TrimRight(baseURL, "/") + } + return defaultBaseURL } // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs. @@ -1844,6 +1876,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool { return ok && enabled } +// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。 +// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。 +// +// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex +// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。 +// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复 +// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat +// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。 +func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool { + if a == nil || !a.IsOpenAI() || a.Extra == nil { + return false + } + enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool) + return ok && enabled +} + // IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。 // 字段:accounts.extra.openai_ws_allow_store_recovery。 func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool { diff --git a/backend/internal/service/account_credentials_redact.go b/backend/internal/service/account_credentials_redact.go index 8eb70513ba..1e32483817 100644 --- a/backend/internal/service/account_credentials_redact.go +++ b/backend/internal/service/account_credentials_redact.go @@ -7,6 +7,8 @@ var SensitiveCredentialKeys = []string{ "access_token", "refresh_token", "id_token", "agent_private_key", // API Key 类 "api_key", "session_key", "cookie", + // Grok Web SSO / password (must never persist or echo after Build OAuth) + "password", "sso_token", "sso", "sso-rw", "clearTextPassword", // 云服务凭据 "aws_secret_access_key", "aws_session_token", "service_account_json", "service_account", "private_key", diff --git a/backend/internal/service/account_grok_media_eligibility.go b/backend/internal/service/account_grok_media_eligibility.go new file mode 100644 index 0000000000..5f05a92772 --- /dev/null +++ b/backend/internal/service/account_grok_media_eligibility.go @@ -0,0 +1,65 @@ +package service + +import infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + +// ValidateGrokMediaEligibilityExtra validates the optional per-account media +// routing override. A nil value removes the override and restores automatic +// provider-observation routing. +func ValidateGrokMediaEligibilityExtra(platform string, extra map[string]any) error { + if platform != PlatformGrok || extra == nil { + return nil + } + raw, exists := extra[GrokMediaEligibleExtraKey] + if !exists || raw == nil { + return nil + } + if _, ok := raw.(bool); !ok { + return infraerrors.BadRequest("GROK_MEDIA_ELIGIBILITY_INVALID", "grok_media_eligible must be a boolean or null") + } + return nil +} + +func normalizeGrokMediaEligibilityExtra(platform string, extra map[string]any) (map[string]any, error) { + if platform != PlatformGrok { + return extra, nil + } + if err := ValidateGrokMediaEligibilityExtra(platform, extra); err != nil { + return nil, err + } + if extra == nil { + return nil, nil + } + normalized := shallowCopyMap(extra) + if normalized[GrokMediaEligibleExtraKey] == nil { + delete(normalized, GrokMediaEligibleExtraKey) + } + return normalized, nil +} + +func normalizeGrokMediaEligibilityUpdateExtra(account *Account, input *UpdateAccountInput, normalized map[string]any) (map[string]any, error) { + if account == nil || account.Platform != PlatformGrok { + return normalized, nil + } + if input == nil { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_INPUT", "account update input is required") + } + if err := ValidateGrokMediaEligibilityExtra(account.Platform, input.Extra); err != nil { + return nil, err + } + if normalized == nil { + normalized = make(map[string]any) + } else { + normalized = shallowCopyMap(normalized) + } + raw, provided := input.Extra[GrokMediaEligibleExtraKey] + if provided { + if raw == nil { + delete(normalized, GrokMediaEligibleExtraKey) + } + return normalized, nil + } + if current, ok := account.Extra[GrokMediaEligibleExtraKey].(bool); ok { + normalized[GrokMediaEligibleExtraKey] = current + } + return normalized, nil +} diff --git a/backend/internal/service/account_grok_media_eligibility_test.go b/backend/internal/service/account_grok_media_eligibility_test.go index a79f9c02ec..47d1f2b94f 100644 --- a/backend/internal/service/account_grok_media_eligibility_test.go +++ b/backend/internal/service/account_grok_media_eligibility_test.go @@ -25,20 +25,6 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { StatusCode: http.StatusOK, WeeklyStatusCode: http.StatusOK, } - freeBilling := &xai.BillingSummary{ - PeriodType: "monthly", - StatusCode: http.StatusOK, - WeeklyStatusCode: http.StatusOK, - MonthlyStatusCode: http.StatusOK, - MonthlyUpdatedAt: "2026-07-17T00:00:00Z", - } - inconclusiveBilling := &xai.BillingSummary{ - StatusCode: http.StatusOK, - WeeklyStatusCode: http.StatusOK, - MonthlyStatusCode: http.StatusBadGateway, - Partial: true, - FailedWindows: []string{"monthly"}, - } weeklyForbidden := &xai.BillingSummary{ StatusCode: http.StatusOK, WeeklyStatusCode: http.StatusForbidden, @@ -61,8 +47,6 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { {name: "non oauth grok account stays eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, want: true, wantReason: "non_oauth"}, {name: "unobserved oauth fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: false, wantReason: "billing_unobserved"}, {name: "weekly paid usage is eligible without inferring from period type", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyAllowance}}, want: true, wantReason: "eligible"}, - {name: "observed free account is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: freeBilling}}, want: false, wantReason: "billing_free_tier"}, - {name: "inconclusive billing fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: inconclusiveBilling}}, want: false, wantReason: "billing_inconclusive"}, {name: "billing forbidden is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: forbiddenBilling}}, want: false, wantReason: "billing_forbidden"}, {name: "weekly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyForbidden}}, want: false, wantReason: "billing_forbidden"}, {name: "monthly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: monthlyForbidden}}, want: false, wantReason: "billing_forbidden"}, diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go new file mode 100644 index 0000000000..b8a9dae80d --- /dev/null +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -0,0 +1,471 @@ +package service + +import ( + "encoding/json" + "math" + "strconv" + "strings" + "time" +) + +// AccountSchedulingThresholdDecision captures the pure pause decision for one account. +type AccountSchedulingThresholdDecision struct { + ShouldPause bool + Platform string + Window string + Scope string + ThresholdPercent int + UsedPercent float64 + Until *time.Time +} + +type accountSchedulingThresholdCandidate struct { + window string + scope string + usedPercent float64 + until *time.Time +} + +const accountSchedulingThresholdCredentialKey = "account_scheduling_threshold" + +// EvaluateAccountSchedulingThreshold evaluates whether an account should be paused +// based on the current per-platform scheduling threshold snapshot. +func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]int, now time.Time) AccountSchedulingThresholdDecision { + decision := AccountSchedulingThresholdDecision{} + if account == nil { + return decision + } + + decision.Platform = strings.ToLower(strings.TrimSpace(account.Platform)) + if decision.Platform == "" { + return decision + } + if !isAllowedSchedulingThresholdPlatform(decision.Platform) { + return decision + } + + threshold, ok := resolveEffectiveAccountSchedulingThreshold(account, thresholds, decision.Platform) + decision.ThresholdPercent = threshold + if !ok || threshold >= 100 { + return decision + } + + var winner *accountSchedulingThresholdCandidate + switch decision.Platform { + case PlatformOpenAI: + winner = pickLatestResetSchedulingCandidate(openAIThresholdCandidates(account, now), threshold, now) + case PlatformAnthropic: + winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) + case PlatformGrok: + winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) + default: + return decision + } + + if winner == nil { + return decision + } + + decision.ShouldPause = true + decision.Window = winner.window + decision.Scope = winner.scope + decision.UsedPercent = winner.usedPercent + decision.Until = winner.until + return decision +} + +func isAllowedSchedulingThresholdPlatform(platform string) bool { + for _, allowed := range AllowedSchedulingThresholdPlatforms { + if platform == allowed { + return true + } + } + return false +} + +func resolveEffectiveAccountSchedulingThreshold(account *Account, thresholds map[string]int, platform string) (int, bool) { + if account != nil { + if threshold, ok := accountSchedulingThresholdOverride(account); ok { + return threshold, true + } + } + return lookupAccountSchedulingThreshold(thresholds, platform) +} + +func accountSchedulingThresholdOverride(account *Account) (int, bool) { + if account == nil || len(account.Credentials) == 0 { + return 0, false + } + raw, ok := account.Credentials[accountSchedulingThresholdCredentialKey] + if !ok { + return 0, false + } + return parseAccountSchedulingThresholdValue(raw) +} + +func parseAccountSchedulingThresholdValue(raw any) (int, bool) { + var value int + switch v := raw.(type) { + case int: + value = v + case int64: + value = int(v) + case float64: + value = int(math.Round(v)) + case float32: + value = int(math.Round(float64(v))) + case json.Number: + parsed, err := v.Float64() + if err != nil { + return 0, false + } + value = int(math.Round(parsed)) + case string: + raw := strings.TrimSpace(v) + parsed, err := strconv.Atoi(raw) + if err == nil { + value = parsed + break + } + parsedFloat, floatErr := strconv.ParseFloat(raw, 64) + if floatErr != nil { + return 0, false + } + value = int(math.Round(parsedFloat)) + default: + return 0, false + } + if value < 1 || value > 100 { + return 0, false + } + return value, true +} + +func lookupAccountSchedulingThreshold(thresholds map[string]int, platform string) (int, bool) { + if len(thresholds) == 0 { + return 0, false + } + value, ok := thresholds[platform] + return value, ok +} + +func openAIThresholdCandidates(account *Account, now time.Time) []*accountSchedulingThresholdCandidate { + if account == nil { + return nil + } + if !openAICodexSnapshotIdentityTrusted(account) { + return nil + } + return []*accountSchedulingThresholdCandidate{ + openAIThresholdCandidate(account.Extra, "5h", now), + openAIThresholdCandidate(account.Extra, "7d", now), + } +} + +func openAICodexSnapshotIdentityTrusted(account *Account) bool { + if account == nil || !account.IsOpenAIOAuth() || len(account.Extra) == 0 { + return true + } + + if identityValuesConflict( + firstStringValue(account.Credentials, "email"), + firstStringValue(account.Extra, "email", "email_address"), + ) { + return false + } + if identityValuesConflict( + firstStringValue(account.Credentials, "chatgpt_account_id"), + firstStringValue(account.Extra, "chatgpt_account_id", "account_id"), + ) { + return false + } + if identityValuesConflict( + firstStringValue(account.Credentials, "workspace_id", "chatgpt_workspace_id", "organization_id", "org_id"), + firstStringValue(account.Extra, "workspace_id", "chatgpt_workspace_id", "organization_id", "org_id"), + ) { + return false + } + return true +} + +func identityValuesConflict(left, right string) bool { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + return left != "" && right != "" && !strings.EqualFold(left, right) +} + +// firstStringValue returns the first non-empty string among the given map keys. +// Used by OpenAI codex snapshot identity matching for scheduling thresholds. +func firstStringValue(values map[string]any, keys ...string) string { + if len(values) == 0 { + return "" + } + for _, key := range keys { + raw, ok := values[key] + if !ok || raw == nil { + continue + } + switch typed := raw.(type) { + case string: + if v := strings.TrimSpace(typed); v != "" { + return v + } + default: + if v := strings.TrimSpace(stringValue(raw)); v != "" { + return v + } + } + } + return "" +} + +func openAIThresholdCandidate(extra map[string]any, window string, now time.Time) *accountSchedulingThresholdCandidate { + if len(extra) == 0 { + return nil + } + + var ( + usedPercentKey string + resetAtKey string + ) + switch window { + case "5h": + usedPercentKey = "codex_5h_used_percent" + resetAtKey = "codex_5h_reset_at" + case "7d": + usedPercentKey = "codex_7d_used_percent" + resetAtKey = "codex_7d_reset_at" + default: + return nil + } + + usedPercent, ok := extra[usedPercentKey] + if !ok { + return nil + } + if openAIQuotaWindowReset(extra, window, now) || openAICodexSnapshotStaleForPause(extra, now) { + return nil + } + return &accountSchedulingThresholdCandidate{ + window: window, + usedPercent: schedulingPercentValue(usedPercent), + until: parseSchedulingResetAt(extra[resetAtKey]), + } +} + +func anthropicThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate { + if account == nil { + return nil + } + + var candidates []*accountSchedulingThresholdCandidate + if usedPercent := utilizationAsPercent(account.Extra["session_window_utilization"]); usedPercent > 0 { + candidates = append(candidates, &accountSchedulingThresholdCandidate{ + window: "5h", + usedPercent: usedPercent, + until: cloneTimePtr(account.SessionWindowEnd), + }) + } + if usedPercent := utilizationAsPercent(account.Extra["passive_usage_7d_utilization"]); usedPercent > 0 { + candidates = append(candidates, &accountSchedulingThresholdCandidate{ + window: "7d", + usedPercent: usedPercent, + until: parseSchedulingResetAt(account.Extra["passive_usage_7d_reset"]), + }) + } + return candidates +} + +// NOTE: Gemini / Kiro / Antigravity are intentionally NOT threshold-pausing +// platforms (see AllowedSchedulingThresholdPlatforms and the evaluator switch, +// asserted by TestEvaluateAccountSchedulingThreshold_UnsupportedPlatformsDoNotPause). +// Their former per-platform candidate readers were dead code — never reachable +// from EvaluateAccountSchedulingThreshold — and have been removed to avoid the +// false impression that configuring a threshold for them has any effect. The +// kiro_sched_* / antigravity_sched_* extras are still written purely as +// observability snapshots. + +// grokThresholdCandidates uses only header-projected +// grok_sched_utilization / grok_sched_reset_at (rolling quota window, reset +// capped at ~25h when written). Official billing 7d/30d windows are not used +// for auto-pause here. +func grokThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate { + if account == nil { + return nil + } + return []*accountSchedulingThresholdCandidate{ + { + window: "quota", + scope: "grok", + usedPercent: schedulingPercentValue(account.Extra["grok_sched_utilization"]), + until: parseSchedulingResetAt(account.Extra["grok_sched_reset_at"]), + }, + } +} + +func pickLatestResetSchedulingCandidate(candidates []*accountSchedulingThresholdCandidate, threshold int, now time.Time) *accountSchedulingThresholdCandidate { + var winner *accountSchedulingThresholdCandidate + for _, candidate := range candidates { + if !candidateMatchesThreshold(candidate, threshold, now) { + continue + } + if winner == nil || candidate.until.After(*winner.until) { + winner = candidate + continue + } + if winner.until.Equal(*candidate.until) && candidate.usedPercent > winner.usedPercent { + winner = candidate + } + } + return winner +} + +func candidateMatchesThreshold(candidate *accountSchedulingThresholdCandidate, threshold int, now time.Time) bool { + if candidate == nil || candidate.until == nil || !candidate.until.After(now) { + return false + } + return candidate.usedPercent >= float64(threshold) +} + +func utilizationAsPercent(raw any) float64 { + switch v := raw.(type) { + case float64: + if v >= 0 && v <= 1 { + return v * 100 + } + return v + case float32: + value := float64(v) + if value >= 0 && value <= 1 { + return value * 100 + } + return value + case int: + return float64(v) + case int64: + return float64(v) + case json.Number: + value, err := v.Float64() + if err != nil { + return 0 + } + if strings.Contains(v.String(), ".") && value >= 0 && value <= 1 { + return value * 100 + } + return value + case string: + trimmed := strings.TrimSpace(v) + value, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0 + } + if strings.Contains(trimmed, ".") && value >= 0 && value <= 1 { + return value * 100 + } + return value + default: + return 0 + } +} + +func schedulingPercentValue(raw any) float64 { + switch v := raw.(type) { + case float64: + return v + case float32: + return float64(v) + case int: + return float64(v) + case int64: + return float64(v) + case json.Number: + value, err := v.Float64() + if err != nil { + return 0 + } + return value + case string: + value, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return 0 + } + return value + default: + return 0 + } +} + +func parseSchedulingResetAt(raw any) *time.Time { + switch v := raw.(type) { + case nil: + return nil + case time.Time: + ts := v + return &ts + case *time.Time: + return cloneTimePtr(v) + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil + } + ts, err := parseSchedulingTime(trimmed) + if err != nil { + return nil + } + return &ts + case json.Number: + if value, err := v.Int64(); err == nil && value > 0 { + ts := time.Unix(value, 0) + return &ts + } + if value, err := v.Float64(); err == nil && value > 0 { + ts := time.Unix(int64(value), 0) + return &ts + } + case float64: + if v > 0 { + ts := time.Unix(int64(v), 0) + return &ts + } + case float32: + if v > 0 { + ts := time.Unix(int64(v), 0) + return &ts + } + case int: + if v > 0 { + ts := time.Unix(int64(v), 0) + return &ts + } + case int64: + if v > 0 { + ts := time.Unix(v, 0) + return &ts + } + } + return nil +} + +func parseSchedulingTime(raw string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000Z", + } + for _, format := range formats { + if ts, err := time.Parse(format, raw); err == nil { + return ts, nil + } + } + return time.Time{}, strconv.ErrSyntax +} + +func cloneTimePtr(src *time.Time) *time.Time { + if src == nil { + return nil + } + value := *src + return &value +} diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go new file mode 100644 index 0000000000..6535431752 --- /dev/null +++ b/backend/internal/service/account_scheduling_threshold_eval_test.go @@ -0,0 +1,436 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +func TestEvaluateAccountSchedulingThreshold_OpenAIChoosesLatestResetWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + wantUntil := now.Add(72 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_5h_used_percent": 90.0, + "codex_5h_reset_at": now.Add(2 * time.Hour).Format(time.RFC3339), + "codex_7d_used_percent": 85.0, + "codex_7d_reset_at": wantUntil.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformOpenAI: 80, + }, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformOpenAI, decision.Platform) + require.Equal(t, "7d", decision.Window) + require.Empty(t, decision.Scope) + require.Equal(t, 85.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, wantUntil.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIIgnoresMismatchedCodexSnapshotIdentity(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 13, 8, 50, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "email": "CageLeen9208@outlook.com", + "chatgpt_account_id": "1f945aa7-d9a9-4369-9542-0c702ff4adb0", + "workspace_id": "org-nU4goUxMmureroyswT5oYPv4", + "chatgpt_workspace_id": "org-nU4goUxMmureroyswT5oYPv4", + }, + Extra: map[string]any{ + "email": "MasonDobies01@outlook.com", + "name": "Paul Clark", + "workspace_id": "org-avRk1G4qdXg7qph3cRIraNKf", + "codex_7d_used_percent": 100.0, + "codex_7d_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformOpenAI: 99, + }, now) + + require.False(t, decision.ShouldPause) +} + +func TestEvaluateAccountSchedulingThreshold_AnthropicIgnoresExpiredFiveHourWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + expiredEnd := now.Add(-30 * time.Minute) + wantUntil := now.Add(5 * 24 * time.Hour) + account := &Account{ + Platform: PlatformAnthropic, + SessionWindowEnd: &expiredEnd, + Extra: map[string]any{ + "session_window_utilization": 0.99, + "passive_usage_7d_utilization": 0.82, + "passive_usage_7d_reset": float64(wantUntil.Unix()), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformAnthropic: 80, + }, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformAnthropic, decision.Platform) + require.Equal(t, "7d", decision.Window) + require.Empty(t, decision.Scope) + require.Equal(t, 82.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, wantUntil.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIPreservesPercentageSemantics(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + openAIUntil := now.Add(24 * time.Hour) + openAIAccount := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_5h_used_percent": 1.0, + "codex_5h_reset_at": openAIUntil.Format(time.RFC3339), + }, + } + + candidate := openAIThresholdCandidate(openAIAccount.Extra, "5h", now) + require.NotNil(t, candidate) + require.Equal(t, 1.0, candidate.usedPercent) + + openAIDecision := EvaluateAccountSchedulingThreshold(openAIAccount, map[string]int{ + PlatformOpenAI: 90, + }, now) + require.False(t, openAIDecision.ShouldPause) + + openAIAccount.Extra["codex_5h_used_percent"] = 91.0 + openAIDecision = EvaluateAccountSchedulingThreshold(openAIAccount, map[string]int{ + PlatformOpenAI: 90, + }, now) + require.True(t, openAIDecision.ShouldPause) + require.Equal(t, 91.0, openAIDecision.UsedPercent) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-2 * time.Hour).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.False(t, decision.ShouldPause) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": now.Add(-time.Second).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.False(t, decision.ShouldPause) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSnapshot(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_5h_used_percent": 100.0, + "codex_5h_reset_at": resetAt.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, "5h", decision.Window) + require.Equal(t, 100.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, resetAt.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_OpenAIPausesFreshExhaustedSevenDayWindow(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + resetAt := now.Add(5 * 24 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339), + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": resetAt.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, "7d", decision.Window) + require.Equal(t, 95.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, resetAt.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_AnthropicPreservesFractionalUtilizationSemantics(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + + anthropicUntil := now.Add(5 * time.Hour) + anthropicAccount := &Account{ + Platform: PlatformAnthropic, + SessionWindowEnd: &anthropicUntil, + Extra: map[string]any{ + "session_window_utilization": 0.92, + }, + } + + anthropicDecision := EvaluateAccountSchedulingThreshold(anthropicAccount, map[string]int{ + PlatformAnthropic: 90, + }, now) + + require.True(t, anthropicDecision.ShouldPause) + require.Equal(t, 92.0, anthropicDecision.UsedPercent) +} + +func TestEvaluateAccountSchedulingThreshold_AccountOverrideCanLowerOpenAIThreshold(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + wantUntil := now.Add(12 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "account_scheduling_threshold": 80, + }, + Extra: map[string]any{ + "codex_7d_used_percent": 85.0, + "codex_7d_reset_at": wantUntil.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformOpenAI: 90, + }, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformOpenAI, decision.Platform) + require.Equal(t, 80, decision.ThresholdPercent) + require.Equal(t, "7d", decision.Window) + require.Empty(t, decision.Scope) + require.Equal(t, 85.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, wantUntil.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_AccountOverrideHundredDisablesOpenAI(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + account := &Account{ + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "account_scheduling_threshold": 100, + }, + Extra: map[string]any{ + "codex_7d_used_percent": 99.0, + "codex_7d_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformOpenAI: 80, + }, now) + + require.False(t, decision.ShouldPause) + require.Equal(t, 100, decision.ThresholdPercent) +} + +func TestEvaluateAccountSchedulingThreshold_AccountOverrideRoundsDecimalThreshold(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + wantUntil := now.Add(12 * time.Hour) + account := &Account{ + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "account_scheduling_threshold": 75.5, + }, + Extra: map[string]any{ + "codex_7d_used_percent": 80.0, + "codex_7d_reset_at": wantUntil.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformOpenAI: 90, + }, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, 76, decision.ThresholdPercent) + require.Equal(t, 80.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, wantUntil.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_UnsupportedPlatformsDoNotPause(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + cases := []struct { + name string + platform string + threshold int + extra map[string]any + }{ + { + name: "gemini", + platform: PlatformGemini, + threshold: 80, + extra: map[string]any{ + "gemini_usage_raw": map[string]any{ + "buckets": []any{ + map[string]any{ + "modelId": "gemini-2.5-pro", + "remainingFraction": 0.05, + "resetTime": now.Add(2 * time.Hour).Format(time.RFC3339), + }, + }, + }, + }, + }, + { + name: "kiro", + platform: PlatformKiro, + threshold: 90, + extra: map[string]any{ + "kiro_sched_utilization": 99.0, + "kiro_sched_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339), + }, + }, + { + name: "antigravity", + platform: PlatformAntigravity, + threshold: 90, + extra: map[string]any{ + "antigravity_sched_utilization": 92.0, + "antigravity_sched_reset_at": now.Add(48 * time.Hour).Format(time.RFC3339), + "antigravity_sched_scope": "gemini", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + account := &Account{ + Platform: tc.platform, + Credentials: map[string]any{ + "account_scheduling_threshold": 1, + }, + Extra: tc.extra, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + tc.platform: tc.threshold, + }, now) + + require.False(t, decision.ShouldPause) + require.Zero(t, decision.ThresholdPercent) + }) + } +} + +func TestEvaluateAccountSchedulingThreshold_GrokUsesConfiguredThresholds(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + wantUntil := now.Add(2 * time.Hour) + account := &Account{ + Platform: PlatformGrok, + Extra: map[string]any{ + "grok_sched_utilization": 92.0, + "grok_sched_reset_at": wantUntil.Format(time.RFC3339), + }, + } + + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{ + PlatformGrok: 90, + }, now) + + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformGrok, decision.Platform) + require.Equal(t, 90, decision.ThresholdPercent) + require.Equal(t, "grok", decision.Scope) + require.Equal(t, 92.0, decision.UsedPercent) + require.NotNil(t, decision.Until) + require.True(t, wantUntil.Equal(*decision.Until)) +} + +func TestEvaluateAccountSchedulingThreshold_GrokUsesOnlyHeaderQuotaWindow(t *testing.T) { + t.Parallel() + // Billing seven_day/thirty_day must not drive pause; only grok_sched_* may. + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + weeklyEnd := now.Add(3 * time.Hour) + weeklyPct := 99.0 + headerUntil := now.Add(2 * time.Hour) + account := &Account{ + Platform: PlatformGrok, + Extra: map[string]any{ + "grok_sched_utilization": 50.0, // below threshold + "grok_sched_reset_at": headerUntil.Format(time.RFC3339), + grokBillingExtraKey: &xai.BillingSummary{ + UsagePercent: &weeklyPct, + PeriodEnd: weeklyEnd.Format(time.RFC3339), + }, + }, + } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) + require.False(t, decision.ShouldPause, "high billing % alone must not pause under scheduling windows") + + account.Extra["grok_sched_utilization"] = 95.0 + decision = EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, "grok", decision.Scope) + require.Equal(t, "quota", decision.Window) + require.NotNil(t, decision.Until) + require.True(t, headerUntil.Equal(*decision.Until)) +} diff --git a/backend/internal/service/account_scheduling_threshold_integration_test.go b/backend/internal/service/account_scheduling_threshold_integration_test.go new file mode 100644 index 0000000000..d2b80dbbcf --- /dev/null +++ b/backend/internal/service/account_scheduling_threshold_integration_test.go @@ -0,0 +1,136 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type thresholdSelectionAccountRepoStub struct { + rateLimitAccountRepoStub + accounts []Account +} + +func (r *thresholdSelectionAccountRepoStub) ListSchedulableByPlatform(_ context.Context, platform string) ([]Account, error) { + filtered := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + if account.Platform == platform { + filtered = append(filtered, account) + } + } + return filtered, nil +} + +func (r *thresholdSelectionAccountRepoStub) ListSchedulableByGroupIDAndPlatform(ctx context.Context, _ int64, platform string) ([]Account, error) { + return r.ListSchedulableByPlatform(ctx, platform) +} + +func (r *thresholdSelectionAccountRepoStub) ListSchedulableUngroupedByPlatform(ctx context.Context, platform string) ([]Account, error) { + return r.ListSchedulableByPlatform(ctx, platform) +} + +func TestGatewayService_ListSchedulableAccounts_DoesNotFilterUnsupportedThresholdPlatforms(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":90}` + + accountRepo := &thresholdSelectionAccountRepoStub{ + accounts: []Account{ + { + ID: 3101, + Platform: PlatformKiro, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "account_scheduling_threshold": 1, + }, + Extra: map[string]any{ + "kiro_sched_utilization": 95.0, + "kiro_sched_reset_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339), + }, + }, + { + ID: 3102, + Platform: PlatformKiro, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "kiro_sched_utilization": 42.0, + "kiro_sched_reset_at": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339), + }, + }, + }, + } + + rateLimitService := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + svc := &GatewayService{ + accountRepo: accountRepo, + cfg: &config.Config{}, + rateLimitService: rateLimitService, + } + + accounts, useMixed, err := svc.listSchedulableAccounts(context.Background(), nil, PlatformKiro, false) + + require.NoError(t, err) + require.False(t, useMixed) + require.Len(t, accounts, 2) + require.Equal(t, int64(3101), accounts[0].ID) + require.Equal(t, int64(3102), accounts[1].ID) + require.Equal(t, 0, accountRepo.tempCalls) +} + +func TestOpenAIGatewayService_ListSchedulableAccounts_FiltersThresholdBlockedAccounts(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":85}` + + accountRepo := &thresholdSelectionAccountRepoStub{ + accounts: []Account{ + { + ID: 4101, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 91.0, + "codex_7d_reset_at": time.Now().UTC().Add(12 * time.Hour).Format(time.RFC3339), + }, + }, + { + ID: 4102, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 40.0, + "codex_7d_reset_at": time.Now().UTC().Add(12 * time.Hour).Format(time.RFC3339), + }, + }, + }, + } + + rateLimitService := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + svc := &OpenAIGatewayService{ + accountRepo: accountRepo, + cfg: &config.Config{}, + rateLimitService: rateLimitService, + } + + accounts, err := svc.listSchedulableAccounts(context.Background(), nil, PlatformOpenAI) + + require.NoError(t, err) + require.Len(t, accounts, 1) + require.Equal(t, int64(4102), accounts[0].ID) + require.Equal(t, 1, accountRepo.tempCalls) +} diff --git a/backend/internal/service/account_scheduling_threshold_reason.go b/backend/internal/service/account_scheduling_threshold_reason.go new file mode 100644 index 0000000000..b43c7e22a1 --- /dev/null +++ b/backend/internal/service/account_scheduling_threshold_reason.go @@ -0,0 +1,188 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +const AccountSchedulingThresholdReasonSource = "account_scheduling_threshold" + +const ( + defaultTempUnschedReasonErrorMessage = "temporary scheduling block reason unavailable" + defaultAccountSchedulingThresholdErrorMessage = "account scheduling threshold reached" +) + +type tempUnschedReasonPayload struct { + Source string `json:"source,omitempty"` + Platform string `json:"platform,omitempty"` + Window string `json:"window,omitempty"` + Scope string `json:"scope,omitempty"` + ThresholdPercent int `json:"threshold_percent,omitempty"` + UsedPercent float64 `json:"used_percent,omitempty"` + UntilUnix int64 `json:"until_unix,omitempty"` + TriggeredAtUnix int64 `json:"triggered_at_unix,omitempty"` + ErrorMessage string `json:"error_message"` +} + +type AccountSchedulingThresholdReasonInput struct { + Platform string + Window string + Scope string + ThresholdPercent int + UsedPercent float64 + Until time.Time + Now time.Time +} + +func BuildTempUnschedReasonPayload(source string, errorMessage string) string { + payload := tempUnschedReasonPayload{ + Source: strings.TrimSpace(source), + ErrorMessage: normalizeTempUnschedReasonErrorMessage(errorMessage, defaultTempUnschedReasonErrorMessage), + } + + raw, err := json.Marshal(payload) + if err != nil { + return payload.ErrorMessage + } + return string(raw) +} + +func BuildAccountSchedulingThresholdReason(errorMessage string) string { + return BuildTempUnschedReasonPayload( + AccountSchedulingThresholdReasonSource, + normalizeTempUnschedReasonErrorMessage(errorMessage, defaultAccountSchedulingThresholdErrorMessage), + ) +} + +func BuildDetailedAccountSchedulingThresholdReason(input AccountSchedulingThresholdReasonInput) string { + triggeredAt := input.Now + if triggeredAt.IsZero() { + triggeredAt = time.Now().UTC() + } + payload := tempUnschedReasonPayload{ + Source: AccountSchedulingThresholdReasonSource, + Platform: strings.TrimSpace(input.Platform), + Window: strings.TrimSpace(input.Window), + Scope: strings.TrimSpace(input.Scope), + ThresholdPercent: input.ThresholdPercent, + UsedPercent: input.UsedPercent, + TriggeredAtUnix: triggeredAt.Unix(), + ErrorMessage: buildAccountSchedulingThresholdErrorMessage(input), + } + if !input.Until.IsZero() { + payload.UntilUnix = input.Until.UTC().Unix() + } + + raw, err := json.Marshal(payload) + if err != nil { + return payload.ErrorMessage + } + return string(raw) +} + +func IsAccountSchedulingThresholdReason(rawReason string) bool { + payload, ok := parseTempUnschedReasonPayload(rawReason) + if !ok { + return false + } + return payload.Source == AccountSchedulingThresholdReasonSource +} + +func parseTempUnschedReasonPayload(rawReason string) (tempUnschedReasonPayload, bool) { + rawReason = strings.TrimSpace(rawReason) + if rawReason == "" { + return tempUnschedReasonPayload{}, false + } + + var payload tempUnschedReasonPayload + if err := json.Unmarshal([]byte(rawReason), &payload); err != nil { + return tempUnschedReasonPayload{}, false + } + payload.Source = strings.TrimSpace(payload.Source) + payload.ErrorMessage = strings.TrimSpace(payload.ErrorMessage) + return payload, true +} + +func normalizeTempUnschedReasonErrorMessage(errorMessage string, fallback string) string { + errorMessage = strings.TrimSpace(errorMessage) + if errorMessage != "" { + return errorMessage + } + + fallback = strings.TrimSpace(fallback) + if fallback != "" { + return fallback + } + return defaultTempUnschedReasonErrorMessage +} + +func buildAccountSchedulingThresholdErrorMessage(input AccountSchedulingThresholdReasonInput) string { + platform := strings.TrimSpace(input.Platform) + if platform == "" { + platform = "account" + } + + target := strings.TrimSpace(input.Window) + if scope := strings.TrimSpace(input.Scope); scope != "" { + if target == "" { + target = scope + } else { + target = target + "/" + scope + } + } + if target == "" { + target = "usage window" + } + + threshold := input.ThresholdPercent + if threshold <= 0 { + threshold = 100 + } + + untilText := "the window reset" + if !input.Until.IsZero() { + untilText = input.Until.UTC().Format(time.RFC3339) + } + + return fmt.Sprintf( + "%s scheduling threshold reached for %s: %.1f%% used >= %d%%; paused until %s", + platform, + target, + input.UsedPercent, + threshold, + untilText, + ) +} + +func tempUnschedStateFromStoredReason(rawReason string, fallbackUntilUnix int64) *TempUnschedState { + state := &TempUnschedState{ + UntilUnix: fallbackUntilUnix, + RuleIndex: -1, + } + + rawReason = strings.TrimSpace(rawReason) + if rawReason == "" { + state.ErrorMessage = defaultTempUnschedReasonErrorMessage + return state + } + + parsed := TempUnschedState{RuleIndex: -1} + if err := json.Unmarshal([]byte(rawReason), &parsed); err == nil { + if fallbackUntilUnix > parsed.UntilUnix { + parsed.UntilUnix = fallbackUntilUnix + } + if strings.TrimSpace(parsed.ErrorMessage) == "" { + if IsAccountSchedulingThresholdReason(rawReason) { + parsed.ErrorMessage = defaultAccountSchedulingThresholdErrorMessage + } else { + parsed.ErrorMessage = rawReason + } + } + return &parsed + } + + state.ErrorMessage = rawReason + return state +} diff --git a/backend/internal/service/account_scheduling_threshold_reason_test.go b/backend/internal/service/account_scheduling_threshold_reason_test.go new file mode 100644 index 0000000000..34eea91cd4 --- /dev/null +++ b/backend/internal/service/account_scheduling_threshold_reason_test.go @@ -0,0 +1,76 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBuildAccountSchedulingThresholdReason_UsesSourceAndFallbackMessage(t *testing.T) { + raw := BuildAccountSchedulingThresholdReason(" \t ") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(raw), &payload)) + require.Equal(t, AccountSchedulingThresholdReasonSource, payload["source"]) + require.Equal(t, defaultAccountSchedulingThresholdErrorMessage, payload["error_message"]) + require.True(t, IsAccountSchedulingThresholdReason(raw)) +} + +func TestIsAccountSchedulingThresholdReason(t *testing.T) { + require.True(t, IsAccountSchedulingThresholdReason(BuildAccountSchedulingThresholdReason("threshold reached"))) + require.False(t, IsAccountSchedulingThresholdReason(BuildTempUnschedReasonPayload("", "temporary block"))) + require.False(t, IsAccountSchedulingThresholdReason("plain text reason")) +} + +func TestTempUnschedStateFromStoredReason_EmptyReasonUsesFallbackErrorMessage(t *testing.T) { + state := tempUnschedStateFromStoredReason(" \n ", 1735689600) + + require.NotNil(t, state) + require.Equal(t, int64(1735689600), state.UntilUnix) + require.Equal(t, defaultTempUnschedReasonErrorMessage, state.ErrorMessage) +} + +func TestTempUnschedStateFromStoredReason_MissingRuleIndexIsSystemRule(t *testing.T) { + state := tempUnschedStateFromStoredReason(`{"error_message":"system cooldown"}`, 123) + require.Equal(t, -1, state.RuleIndex) +} + +func TestTempUnschedStateFromStoredReason_SchedulingThresholdJSONWithoutMessageUsesThresholdFallback(t *testing.T) { + raw := `{"source":"` + AccountSchedulingThresholdReasonSource + `"}` + + state := tempUnschedStateFromStoredReason(raw, 1735689600) + + require.NotNil(t, state) + require.Equal(t, int64(1735689600), state.UntilUnix) + require.Equal(t, defaultAccountSchedulingThresholdErrorMessage, state.ErrorMessage) +} + +func TestBuildDetailedAccountSchedulingThresholdReason_IncludesReadableFields(t *testing.T) { + now := time.Unix(1735689600, 0).UTC() + until := now.Add(5 * time.Hour) + + raw := BuildDetailedAccountSchedulingThresholdReason(AccountSchedulingThresholdReasonInput{ + Platform: PlatformOpenAI, + Window: "7d", + ThresholdPercent: 90, + UsedPercent: 92.5, + Until: until, + Now: now, + }) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &payload)) + require.Equal(t, AccountSchedulingThresholdReasonSource, payload["source"]) + require.Equal(t, PlatformOpenAI, payload["platform"]) + require.Equal(t, "7d", payload["window"]) + require.Equal(t, float64(90), payload["threshold_percent"]) + require.Equal(t, float64(92.5), payload["used_percent"]) + require.Equal(t, float64(until.Unix()), payload["until_unix"]) + require.Equal(t, float64(now.Unix()), payload["triggered_at_unix"]) + require.Contains(t, payload["error_message"], "openai scheduling threshold reached") + require.Contains(t, payload["error_message"], "92.5% used >= 90%") +} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 98263dca1c..65d4d3f268 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -131,11 +131,25 @@ type AccountDuplicateRepository interface { CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error } +// AccountBillingSettingsRepository applies an admin edit without overwriting a +// rate_multiplier that a successful upstream probe synchronized after the edit +// form was loaded. A nil rateMultiplier means the request did not edit it. +type AccountBillingSettingsRepository interface { + UpdateWithAccountBillingSettings( + ctx context.Context, + account *Account, + probeEnabled *bool, + rateSyncEnabled *bool, + rateMultiplier *float64, + ) error +} + // AdminAccountRepository makes the account-duplication write capability an explicit // construction dependency without forcing read-only gateway test doubles to implement it. type AdminAccountRepository interface { AccountRepository AccountDuplicateRepository + AccountBillingSettingsRepository } // AccountBulkUpdate describes the fields that can be updated in a bulk operation. @@ -218,7 +232,7 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( Notes: normalizeAccountNotes(req.Notes), Platform: req.Platform, Type: req.Type, - Credentials: req.Credentials, + Credentials: SanitizeStoredCredentials(req.Platform, req.Credentials), Extra: req.Extra, ProxyID: req.ProxyID, Concurrency: req.Concurrency, @@ -311,7 +325,7 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount } if req.Credentials != nil { - account.Credentials = *req.Credentials + account.Credentials = SanitizeStoredCredentials(account.Platform, *req.Credentials) } if req.Extra != nil { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 9b530aa6ec..5a62598db2 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -5,14 +5,22 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" "io" "log" + "mime/multipart" "net/http" "net/http/httptest" + "net/url" "regexp" "strings" "sync" @@ -27,6 +35,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/tidwall/gjson" ) // sseDataPrefix matches SSE data lines with optional whitespace after colon. @@ -46,16 +55,53 @@ type TestEvent struct { Status string `json:"status,omitempty"` Code string `json:"code,omitempty"` ImageURL string `json:"image_url,omitempty"` + // AudioURL / VideoURL are data: or https URLs for in-browser media players. + AudioURL string `json:"audio_url,omitempty"` + VideoURL string `json:"video_url,omitempty"` MimeType string `json:"mime_type,omitempty"` Data any `json:"data,omitempty"` Success bool `json:"success,omitempty"` Error string `json:"error,omitempty"` } +// AccountTestOptions carries optional media for admin connectivity tests. +// ImageDataURL / AudioDataURL are full data URLs (data:;base64,...). +type AccountTestOptions struct { + ImageDataURL string + AudioDataURL string +} + +func firstAccountTestOptions(opts []AccountTestOptions) AccountTestOptions { + if len(opts) == 0 { + return AccountTestOptions{} + } + return opts[0] +} + +// maxAccountTestMediaBytes caps inbound data-URL payloads for admin tests (~8 MiB). +const maxAccountTestMediaBytes = 8 << 20 + const ( defaultGeminiTextTestPrompt = "hi" defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." + defaultGrokImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." + defaultGrokVideoTestPrompt = "A red ball bouncing once on a white floor, short simple motion." + defaultGrokSearchTestQuery = "xAI Grok" + defaultGrokTTSTestText = "Hello from Sub2API account connectivity test." + + // Grok account-test modes (admin UI). Empty / default / text = Responses probe. + // image/video may also be inferred from model_id when mode is default. + AccountTestModeGrokText = "text" + AccountTestModeGrokImage = "image" + AccountTestModeGrokVideo = "video" + AccountTestModeGrokSearch = "search" + AccountTestModeGrokTTS = "tts" + AccountTestModeGrokSTT = "stt" + AccountTestModeGrokRealtime = "realtime" + + defaultGrokRealtimeTestModel = "grok-voice-latest" + grokRealtimeProbeTimeout = 12 * time.Second ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). @@ -63,6 +109,32 @@ func isOpenAIImageModel(model string) bool { return strings.HasPrefix(strings.ToLower(model), "gpt-image-") } +func isGrokVideoGenerationModel(model string) bool { + return isGrokVideoBillingModel(model) || + strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-video") +} + +func normalizeGrokAccountTestMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case AccountTestModeGrokText: + return AccountTestModeGrokText + case AccountTestModeGrokImage: + return AccountTestModeGrokImage + case AccountTestModeGrokVideo: + return AccountTestModeGrokVideo + case AccountTestModeGrokSearch: + return AccountTestModeGrokSearch + case AccountTestModeGrokTTS: + return AccountTestModeGrokTTS + case AccountTestModeGrokSTT: + return AccountTestModeGrokSTT + case AccountTestModeGrokRealtime: + return AccountTestModeGrokRealtime + default: + return AccountTestModeDefault + } +} + // AccountTestService handles account testing operations type AccountTestService struct { accountRepo AccountRepository @@ -72,9 +144,19 @@ type AccountTestService struct { antigravityGatewayService *AntigravityGatewayService httpUpstream HTTPUpstream cfg *config.Config + settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator + // grokWSDialer is optional; realtime account tests use the default OpenAI-style + // WS dialer when nil (supports proxy + coder/websocket handshake). + grokWSDialer openAIWSClientDialer +} + +func (s *AccountTestService) SetSettingService(settingService *SettingService) { + if s != nil { + s.settingService = settingService + } } // NewAccountTestService creates a new AccountTestService @@ -177,8 +259,10 @@ func createTestPayload(modelID string) (map[string]any, error) { // All account types use full Claude Code client characteristics, only auth header differs // modelID is optional - if empty, defaults to claude.DefaultTestModel // mode is optional - "compact" routes OpenAI accounts to the /responses/compact probe path -func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string) error { +// opts is optional media (image/audio data URLs for real generation / STT). +func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string, opts ...AccountTestOptions) error { ctx := c.Request.Context() + testOpts := firstAccountTestOptions(opts) // Get account account, err := s.accountRepo.GetByID(ctx, accountID) @@ -210,7 +294,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } if account.Platform == PlatformGrok { - return s.testGrokAccountConnection(c, account, modelID) + return s.testGrokAccountConnection(c, account, modelID, prompt, mode, testOpts) } if account.Platform == PlatformAntigravity { @@ -646,15 +730,16 @@ 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", "codex_cli_rs") + req.Header.Set("Originator", openai.CodexDefaultOriginator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { req.Header.Set("User-Agent", codexCLIUserAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) - // 与真实转发一致:originator 与最终 User-Agent 首段配套,否则上游 404(issue #3901)。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 + // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -705,14 +790,61 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.processOpenAIStream(c, resp.Body) } -// testGrokAccountConnection tests a Grok OAuth or API-key account through xAI's Responses API. -func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account *Account, modelID string) error { +// testGrokAccountConnection routes Grok admin connectivity tests by explicit mode first, +// then by selected model family for media. Standalone modes (search/tts/stt) never share +// the text Responses path; image/video never hit Responses either. +// +// Modes: +// - default/text → Responses (optional model) +// - image → /v1/images/generations (model optional; defaults to grok-imagine-image) +// - video → /v1/videos/generations (model optional; defaults to grok-imagine-video) +// - search → standalone web-search probe (gateway /v1/web_search semantics) +// - tts → HTTP /v1/tts +// - stt → HTTP /v1/stt (synthetic tiny wav probe) +// - realtime → WS /v1/realtime dial + optional first server event +// +// When mode is default, image/video can still be inferred from model_id for backward compat. +func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account *Account, modelID, prompt, mode string, opts AccountTestOptions) error { ctx := c.Request.Context() - if s.httpUpstream == nil { + // Realtime is WebSocket-only and does not need HTTP upstream. + mode = normalizeGrokAccountTestMode(mode) + if mode != AccountTestModeGrokRealtime && s.httpUpstream == nil { return s.sendErrorAndEnd(c, "HTTP upstream not configured") } + authToken, err := s.grokTestAccessToken(ctx, account) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + + // Explicit standalone / media modes always win over model id. + switch mode { + case AccountTestModeGrokSearch: + return s.testGrokWebSearch(c, ctx, account, authToken, prompt) + case AccountTestModeGrokTTS: + return s.testGrokTTS(c, ctx, account, authToken, prompt) + case AccountTestModeGrokSTT: + return s.testGrokSTT(c, ctx, account, authToken, opts.AudioDataURL) + case AccountTestModeGrokRealtime: + return s.testGrokRealtime(c, ctx, account, authToken, modelID) + case AccountTestModeGrokImage: + return s.testGrokImageGeneration(c, ctx, account, authToken, resolveGrokImageTestModel(account, modelID), resolveGrokImagePrompt(prompt), opts.ImageDataURL) + case AccountTestModeGrokVideo: + return s.testGrokVideoGeneration(c, ctx, account, authToken, resolveGrokVideoTestModel(account, modelID), resolveGrokVideoPrompt(prompt), opts) + case AccountTestModeGrokText: + // Force text Responses even if model_id looks like media. + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = grokDefaultResponsesModel + } + if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { + testModelID = mapped + } + return s.testGrokResponsesConnection(c, ctx, account, authToken, testModelID) + } + + // mode == default: infer from model family (legacy UI / API clients). testModelID := strings.TrimSpace(modelID) if testModelID == "" { testModelID = grokDefaultResponsesModel @@ -721,73 +853,121 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account * testModelID = mapped } - var authToken string + switch { + case isGrokImageGenerationModel(testModelID): + return s.testGrokImageGeneration(c, ctx, account, authToken, testModelID, resolveGrokImagePrompt(prompt), opts.ImageDataURL) + case isGrokVideoGenerationModel(testModelID): + return s.testGrokVideoGeneration(c, ctx, account, authToken, testModelID, resolveGrokVideoPrompt(prompt), opts) + default: + return s.testGrokResponsesConnection(c, ctx, account, authToken, testModelID) + } +} + +func resolveGrokImagePrompt(prompt string) string { + if strings.TrimSpace(prompt) == "" { + return defaultGrokImageTestPrompt + } + return strings.TrimSpace(prompt) +} + +func resolveGrokVideoPrompt(prompt string) string { + if strings.TrimSpace(prompt) == "" { + return defaultGrokVideoTestPrompt + } + return strings.TrimSpace(prompt) +} + +func resolveGrokImageTestModel(account *Account, modelID string) string { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = "grok-imagine-image" + } + if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { + return mapped + } + return testModelID +} + +func resolveGrokVideoTestModel(account *Account, modelID string) string { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = "grok-imagine-video" + } + if mapped := strings.TrimSpace(account.GetMappedModel(testModelID)); mapped != "" { + return mapped + } + return testModelID +} + +func (s *AccountTestService) grokTestAccessToken(ctx context.Context, account *Account) (string, error) { switch account.Type { case AccountTypeOAuth: if s.grokTokenProvider == nil { - return s.sendErrorAndEnd(c, "Grok token provider not configured") + return "", fmt.Errorf("grok token provider not configured") } - var err error - // 手动测试不走生产调度资格门:关闭调度、限流/过载/临时冷却中的账号 - // 也应能被管理员探测(#4598),与 Codex/OpenAI 测试行为一致。 - authToken, err = s.grokTokenProvider.GetAccessTokenForManualTest(ctx, account) + // Manual tests skip production scheduling eligibility so paused/rate-limited + // accounts can still be probed by admins (same as Codex/OpenAI tests). + token, err := s.grokTokenProvider.GetAccessTokenForManualTest(ctx, account) if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to get Grok access token: %s", err.Error())) + return "", fmt.Errorf("failed to get grok access token: %s", err.Error()) } + return token, nil case AccountTypeAPIKey: - authToken = strings.TrimSpace(account.GetCredential("api_key")) + authToken := strings.TrimSpace(account.GetCredential("api_key")) if authToken == "" { - return s.sendErrorAndEnd(c, "Grok API key is missing") + return "", fmt.Errorf("grok api key is missing") } + return authToken, nil default: - return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Grok account type: %s", account.Type)) + return "", fmt.Errorf("unsupported grok account type: %s", account.Type) } +} - apiURL, err := buildGrokResponsesURL(account, s.cfg) - if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok base URL: %s", err.Error())) +func (s *AccountTestService) grokTestProxyURL(account *Account) string { + if account.ProxyID != nil && account.Proxy != nil { + return account.Proxy.URL() } + return "" +} +func (s *AccountTestService) prepareGrokTestSSE(c *gin.Context) { 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() +} - payloadBytes, err := buildGrokQuotaProbeBody(testModelID) - if err != nil { - return s.sendErrorAndEnd(c, "Failed to create Grok test payload") - } - - if !agentIdentityTaskRecoveryWasTried(ctx) { - 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 Grok request") - } +func (s *AccountTestService) applyGrokTestRequestHeaders(req *http.Request, account *Account, authToken string, accept string) { req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") + if accept != "" { + req.Header.Set("Accept", accept) + } req.Header.Set("Authorization", "Bearer "+authToken) - if account.IsGrokOAuth() { + // Match gateway media/voice: CLI identity headers only on the CLI chat proxy. + // api.x.ai media (images/videos) rejects or mistreats OAuth when CLI headers + // are stamped on the official API host (e.g. ZDR upload_url false positives). + if account.IsGrokOAuth() && req.URL != nil && isGrokCLIProxyTarget(req.URL.String()) { applyGrokCLIHeaders(req.Header) } - // 连通性测试与真实转发保持同一套账号级请求头覆写。 account.ApplyHeaderOverrides(req.Header) +} - proxyURL := "" - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() +func (s *AccountTestService) observeGrokTestResponse(ctx context.Context, account *Account, resp *http.Response) { + if resp == nil { + return } - - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) - if err != nil { - return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API request failed: %s", err.Error())) - } - defer func() { _ = resp.Body.Close() }() - now := time.Now() + // Error bodies carry Grok's free-usage, billing, and content-policy + // classifications when quota headers are absent. Read only non-success + // responses here, then restore the body because the caller still needs it + // for the user-facing test result. + var responseBody []byte + if resp.StatusCode >= http.StatusBadRequest && resp.Body != nil { + responseBody, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(responseBody)) + } snapshot := parseGrokQuotaSnapshot(resp.Header, resp.StatusCode, now) if snapshot != nil && s.accountRepo != nil { resetAt, limited := grokRateLimitResetAtForAccount(account, snapshot, now) @@ -805,25 +985,933 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account * } else if s.accountRepo != nil && isSuccessfulGrokRateLimitRecovery(account, &xai.QuotaSnapshot{StatusCode: resp.StatusCode}) { clearGrokRateLimitAfterRecovery(ctx, s.accountRepo, account) } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + if s.accountRepo == nil || len(responseBody) == 0 { if resp.StatusCode == http.StatusPaymentRequired && s.accountRepo != nil { stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() - _ = s.accountRepo.SetTempUnschedulable( - stateCtx, - account.ID, - time.Now().Add(30*time.Minute), - "grok payment required", - ) + _ = s.accountRepo.SetTempUnschedulable(stateCtx, account.ID, now.Add(30*time.Minute), "grok payment required") } + return + } + if isGrokContentPolicyRejection(resp.StatusCode, responseBody) { + return + } + decision := classifyGrokUpstreamFailure(resp.StatusCode, responseBody, "") + if decision.Class == GrokFailureFreeUsage { + if resetAt, limited := grokRateLimitResetAtForAccount(account, snapshot, now); limited && resetAt.After(now) { + persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) + } else { + stateCtx, cancel := openAIAccountStateContext(ctx) + _ = s.accountRepo.SetTempUnschedulable(stateCtx, account.ID, now.Add(grokFreeUsageProbeCooldown), "grok free usage exhausted") + cancel() + } + return + } + if decision.Class == GrokFailureBilling && (isGrokSpendingLimitError(responseBody) || strings.Contains(strings.ToLower(decision.Reason), "credit")) { + persistGrokRateLimit(ctx, s.accountRepo, account, grokSpendingLimitResetAt(account, now)) + return + } + cooldown := time.Duration(0) + reason := "" + switch resp.StatusCode { + case http.StatusUnauthorized: + cooldown, reason = 10*time.Minute, "grok oauth token unauthorized" + case http.StatusPaymentRequired: + cooldown, reason = 30*time.Minute, "grok payment required" + case http.StatusForbidden: + cooldown, reason = 30*time.Minute, "grok entitlement or subscription tier denied" + default: + if resp.StatusCode >= 500 { + cooldown, reason = 2*time.Minute, "grok upstream temporary error" + } + } + if decision.Class == GrokFailureBilling && cooldown == 0 { + cooldown, reason = 30*time.Minute, "grok payment required" + } + if cooldown > 0 { + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + until := now.Add(cooldown) + if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(until) { + until = *account.TempUnschedulableUntil + } + _ = s.accountRepo.SetTempUnschedulable( + stateCtx, + account.ID, + until, + reason, + ) + } +} + +func (s *AccountTestService) testGrokResponsesConnection(c *gin.Context, ctx context.Context, account *Account, authToken, testModelID string) error { + apiURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok base URL: %s", err.Error())) + } + + s.prepareGrokTestSSE(c) + + payloadBytes, err := buildGrokQuotaProbeBody(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok test payload") + } + + if !agentIdentityTaskRecoveryWasTried(ctx) { + 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 Grok request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "application/json, text/event-stream") + + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + s.observeGrokTestResponse(ctx, account, resp) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) } return s.processOpenAIStream(c, resp.Body) } +func (s *AccountTestService) testGrokImageGeneration(c *gin.Context, ctx context.Context, account *Account, authToken, modelID, prompt, imageDataURL string) error { + // With a source image, prefer /images/edits; otherwise /images/generations. + endpoint := GrokMediaEndpointImagesGenerations + imageDataURL = strings.TrimSpace(imageDataURL) + hasSourceImage := imageDataURL != "" + if hasSourceImage { + endpoint = GrokMediaEndpointImagesEdits + } + + // Align model aliases with gateway (e.g. grok-imagine → grok-imagine-image-quality). + modelID = NormalizeGrokMediaModelForEndpoint(endpoint, modelID, hasSourceImage) + if modelID == "" { + modelID = "grok-imagine-image-quality" + } + + apiURL, err := buildGrokMediaURL(account, s.cfg, endpoint, "") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok media base URL: %s", err.Error())) + } + + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) + if endpoint == GrokMediaEndpointImagesEdits { + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/images/edits with uploaded source image..."}) + } else { + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/images/generations..."}) + } + + // Zero-data-retention teams reject URL format; always request base64 for admin tests. + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "n": 1, + "response_format": "b64_json", + } + if hasSourceImage { + normalized, err := normalizeAccountTestImageDataURL(imageDataURL) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + // Match gateway prepareGrokMediaForwardBody shape: {url, type:image_url}. + payload["image"] = grokMediaImageObject(normalized) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("source image ready (%d chars data URL)\n", len(normalized))}) + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to marshal Grok image request") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok image request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") + req.ContentLength = int64(len(payloadBytes)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(payloadBytes)), nil + } + + // One retry on transport EOF (proxies occasionally drop large edit payloads). + var resp *http.Response + var doErr error + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + s.sendEvent(c, TestEvent{Type: "status", Text: "Retrying Grok image request after transport error..."}) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok image retry request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") + req.ContentLength = int64(len(payloadBytes)) + } + resp, doErr = s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if doErr == nil { + break + } + if !isTransientGrokTransportError(doErr) || attempt == 1 { + return s.sendErrorAndEnd(c, formatGrokImageTransportError(doErr, hasSourceImage, len(payloadBytes))) + } + } + defer func() { _ = resp.Body.Close() }() + s.observeGrokTestResponse(ctx, account, resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read Grok image response: %s", err.Error())) + } + if resp.StatusCode != http.StatusOK { + return s.sendErrorAndEnd(c, formatGrokImagesAPIError(resp.StatusCode, body, hasSourceImage)) + } + + var result struct { + Data []struct { + URL string `json:"url"` + B64JSON string `json:"b64_json"` + RevisedPrompt string `json:"revised_prompt"` + MimeType string `json:"mime_type"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse Grok image response: %s", err.Error())) + } + if len(result.Data) == 0 { + return s.sendErrorAndEnd(c, "No images returned from Grok API") + } + + for _, item := range result.Data { + if item.RevisedPrompt != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt}) + } + mimeType := strings.TrimSpace(item.MimeType) + if mimeType == "" { + mimeType = "image/jpeg" + } + switch { + case strings.TrimSpace(item.B64JSON) != "": + s.sendEvent(c, TestEvent{ + Type: "image", + ImageURL: "data:" + mimeType + ";base64," + item.B64JSON, + MimeType: mimeType, + }) + case strings.TrimSpace(item.URL) != "": + s.sendEvent(c, TestEvent{Type: "image", ImageURL: item.URL, MimeType: mimeType}) + } + } + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testGrokVideoGeneration(c *gin.Context, ctx context.Context, account *Account, authToken, modelID, prompt string, opts AccountTestOptions) error { + apiURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideosGenerations, "") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok media base URL: %s", err.Error())) + } + + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID}) + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling Grok /v1/videos/generations..."}) + + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "duration": 6, + "aspect_ratio": "16:9", + "resolution": "480p", + } + if img := strings.TrimSpace(opts.ImageDataURL); img != "" { + normalized, err := normalizeAccountTestImageDataURL(img) + if err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + // First-frame / image-to-video input (xAI image field). + payload["image"] = grokMediaImageObject(normalized) + s.sendEvent(c, TestEvent{Type: "content", Text: "using uploaded first-frame / reference image\n"}) + } + payloadBytes, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok video request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") + + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + s.observeGrokTestResponse(ctx, account, resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read Grok video response: %s", err.Error())) + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusCreated { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok videos API returned %d: %s", resp.StatusCode, string(body))) + } + + requestID := strings.TrimSpace(gjson.GetBytes(body, "request_id").String()) + if requestID == "" { + requestID = strings.TrimSpace(gjson.GetBytes(body, "id").String()) + } + if requestID == "" { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video create response missing request_id: %s", string(body))) + } + + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("video request accepted: %s\n", requestID)}) + s.sendEvent(c, TestEvent{Type: "status", Text: "Polling video status until done (max ~60s)..."}) + + statusURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideoStatus, requestID) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok video status URL: %s", err.Error())) + } + + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return s.sendErrorAndEnd(c, "Grok video poll canceled") + } + statusReq, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok video status request") + } + s.applyGrokTestRequestHeaders(statusReq, account, authToken, "application/json") + statusResp, err := s.httpUpstream.Do(statusReq, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video status failed: %s", err.Error())) + } + statusBody, _ := io.ReadAll(statusResp.Body) + _ = statusResp.Body.Close() + if statusResp.StatusCode != http.StatusOK && statusResp.StatusCode != http.StatusAccepted { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video status returned %d: %s", statusResp.StatusCode, string(statusBody))) + } + st := strings.ToLower(strings.TrimSpace(gjson.GetBytes(statusBody, "status").String())) + progress := gjson.GetBytes(statusBody, "progress") + if progress.Exists() { + s.sendEvent(c, TestEvent{Type: "status", Text: fmt.Sprintf("status=%s progress=%v", st, progress.Value())}) + } else { + s.sendEvent(c, TestEvent{Type: "status", Text: "status=" + st}) + } + switch st { + case "done", "completed", "succeeded", "success": + return s.emitGrokVideoResult(c, ctx, account, authToken, requestID, statusBody) + case "failed", "error", "canceled", "cancelled": + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video failed: %s", string(statusBody))) + } + select { + case <-ctx.Done(): + return s.sendErrorAndEnd(c, "Grok video poll canceled") + case <-time.After(3 * time.Second): + } + } + return s.sendErrorAndEnd(c, "Grok video still processing after 60s (request_id="+requestID+")") +} + +// emitGrokVideoResult surfaces a playable video URL or downloads /content as data URL. +func (s *AccountTestService) emitGrokVideoResult(c *gin.Context, ctx context.Context, account *Account, authToken, requestID string, statusBody []byte) error { + videoURL := firstNonEmpty( + strings.TrimSpace(gjson.GetBytes(statusBody, "video.url").String()), + strings.TrimSpace(gjson.GetBytes(statusBody, "url").String()), + strings.TrimSpace(gjson.GetBytes(statusBody, "video_url").String()), + strings.TrimSpace(gjson.GetBytes(statusBody, "download_url").String()), + ) + if videoURL != "" && (strings.HasPrefix(videoURL, "http://") || strings.HasPrefix(videoURL, "https://") || strings.HasPrefix(videoURL, "data:")) { + s.sendEvent(c, TestEvent{Type: "content", Text: "video ready: " + videoURL + "\n"}) + s.sendEvent(c, TestEvent{Type: "video", VideoURL: videoURL, MimeType: "video/mp4"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + + // Fetch binary content via official /videos/{id}/content (Bearer-authenticated). + contentURL, err := buildGrokMediaURL(account, s.cfg, GrokMediaEndpointVideoContent, requestID) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok video content URL: %s", err.Error())) + } + s.sendEvent(c, TestEvent{Type: "status", Text: "Downloading video content for preview..."}) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, contentURL, nil) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok video content request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "video/*, application/octet-stream, */*") + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video content download failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) // 64 MiB cap for admin preview + if resp.StatusCode != http.StatusOK { + // Fall back to status URL when binary content is unavailable. + if videoURL != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: "video completed; content download unavailable, reported url=" + videoURL + "\n"}) + s.sendEvent(c, TestEvent{Type: "video", VideoURL: videoURL, MimeType: "video/mp4"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok video content returned %d: %s", resp.StatusCode, truncateString(string(body), 300))) + } + ct := resp.Header.Get("Content-Type") + if ct == "" || strings.HasPrefix(ct, "application/octet-stream") { + ct = "video/mp4" + } + // Keep only type/subtype for data URL. + if i := strings.Index(ct, ";"); i >= 0 { + ct = strings.TrimSpace(ct[:i]) + } + dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(body) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("video content downloaded: content-type=%s bytes=%d\n", ct, len(body))}) + s.sendEvent(c, TestEvent{Type: "video", VideoURL: dataURL, MimeType: ct}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testGrokWebSearch(c *gin.Context, ctx context.Context, account *Account, authToken, query string) error { + query = strings.TrimSpace(query) + if query == "" { + query = defaultGrokSearchTestQuery + } + + // Account-test "web_search" mode mirrors the standalone gateway endpoint + // POST /v1/web_search (not a free-form chat with tools). Implementation still + // uses the same DoGrokNativeResponsesJSON helper as the gateway handler so + // results match production search. + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-web-search"}) + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone web_search probe (same as gateway /v1/web_search)..."}) + + // Keep parity with handler.buildGrokWebSearchPrompt / include sources. + const maxResults = 5 + prompt := fmt.Sprintf( + `Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown. + +User query: +%s`, maxResults, query) + payload := map[string]any{ + "model": grokDefaultResponsesModel, + "input": prompt, + "tools": []map[string]any{{"type": "web_search"}}, + "include": []string{"web_search_call.action.sources"}, + "store": false, + "stream": false, + } + payloadBytes, _ := json.Marshal(payload) + + apiURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok base URL: %s", err.Error())) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create standalone web_search probe request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "application/json") + + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("standalone web_search probe failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + s.observeGrokTestResponse(ctx, account, resp) + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return s.sendErrorAndEnd(c, fmt.Sprintf("standalone web_search probe returned %d: %s", resp.StatusCode, string(body))) + } + + // Normalize like gateway extractGrokWebSearchSources (URL-only sources are enough for connectivity). + sourceCount := 0 + gjson.GetBytes(body, "output").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() != "web_search_call" { + return true + } + sources := item.Get("action.sources") + if sources.IsArray() { + sourceCount += len(sources.Array()) + } + return true + }) + searchCount := countGrokNativeSearchCallsFromJSONBytes(body) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("web_search ok: query=%q tool_calls=%d sources=%d\n", query, searchCount, sourceCount)}) + // Optional: first structured result title if model returned JSON text. + gjson.GetBytes(body, "output").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() != "message" { + return true + } + for _, part := range item.Get("content").Array() { + text := strings.TrimSpace(part.Get("text").String()) + if text == "" { + continue + } + if len(text) > 300 { + text = text[:300] + "..." + } + s.sendEvent(c, TestEvent{Type: "content", Text: text + "\n"}) + return false + } + return true + }) + if searchCount == 0 && sourceCount == 0 { + return s.sendErrorAndEnd(c, "standalone web_search probe completed but no search sources/tool calls were observed") + } + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testGrokTTS(c *gin.Context, ctx context.Context, account *Account, authToken, text string) error { + text = strings.TrimSpace(text) + if text == "" { + text = defaultGrokTTSTestText + } + apiURL, err := buildGrokVoiceURL(account, s.cfg, "tts") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok TTS URL: %s", err.Error())) + } + + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-voice-tts"}) + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/tts..."}) + + // xAI requires `language`; optional voice_id. Prefer the shape that matches + // live gateway probes (text + language [+ voice_id]). + payloads := []map[string]any{ + {"text": text, "language": "en", "voice_id": "Ara"}, + {"text": text, "language": "en"}, + {"text": text, "language": "English", "voice_id": "Ara"}, + } + var lastBody string + var lastCode int + for _, payload := range payloads { + payloadBytes, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok TTS request") + } + s.applyGrokTestRequestHeaders(req, account, authToken, "audio/*, application/json, */*") + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok TTS failed: %s", err.Error())) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + s.observeGrokTestResponse(ctx, account, resp) + lastCode = resp.StatusCode + lastBody = string(body) + if resp.StatusCode == http.StatusOK { + ct := resp.Header.Get("Content-Type") + if ct == "" { + ct = "audio/mpeg" + } + if i := strings.Index(ct, ";"); i >= 0 { + ct = strings.TrimSpace(ct[:i]) + } + // Cap preview size so SSE stays manageable (~4 MiB audio). + if len(body) > 4<<20 { + body = body[:4<<20] + } + audioURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(body) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("tts ok: content-type=%s bytes=%d\n", ct, len(body))}) + s.sendEvent(c, TestEvent{Type: "audio", AudioURL: audioURL, MimeType: ct}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + if resp.StatusCode < 400 || resp.StatusCode >= 500 { + break + } + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok TTS returned %d: %s", lastCode, lastBody)) +} + +// testGrokSTT posts audio to /v1/stt. When audioDataURL is set, uses the +// uploaded file; otherwise a tiny synthetic silent WAV for connectivity only. +func (s *AccountTestService) testGrokSTT(c *gin.Context, ctx context.Context, account *Account, authToken, audioDataURL string) error { + apiURL, err := buildGrokVoiceURL(account, s.cfg, "stt") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok STT URL: %s", err.Error())) + } + + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: "grok-voice-stt"}) + + var audioBytes []byte + filename := "probe.wav" + if audioDataURL = strings.TrimSpace(audioDataURL); audioDataURL != "" { + if err := validateAccountTestDataURL(audioDataURL, "audio/"); err != nil { + return s.sendErrorAndEnd(c, err.Error()) + } + raw, mime, err := decodeAccountTestDataURL(audioDataURL) + if err != nil { + return s.sendErrorAndEnd(c, "Invalid audio data URL: "+err.Error()) + } + audioBytes = raw + filename = sttFilenameForMIME(mime) + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/stt with uploaded audio..."}) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("uploaded audio: mime=%s bytes=%d\n", mime, len(audioBytes))}) + } else { + audioBytes = minimalSilentWAV() + s.sendEvent(c, TestEvent{Type: "status", Text: "Calling standalone /v1/stt with a synthetic silent WAV..."}) + } + + var bodyBuf bytes.Buffer + w := multipart.NewWriter(&bodyBuf) + part, err := w.CreateFormFile("file", filename) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to build STT multipart body") + } + if _, err := part.Write(audioBytes); err != nil { + return s.sendErrorAndEnd(c, "Failed to write STT audio part") + } + _ = w.WriteField("model", "grok-stt") + _ = w.WriteField("language", "en") + if err := w.Close(); err != nil { + return s.sendErrorAndEnd(c, "Failed to finalize STT multipart body") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, &bodyBuf) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Grok STT request") + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+authToken) + if account.IsGrokOAuth() { + applyGrokCLIHeaders(req.Header) + } + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.httpUpstream.Do(req, s.grokTestProxyURL(account), account.ID, account.Concurrency) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok STT failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + s.observeGrokTestResponse(ctx, account, resp) + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + // 4xx on synthetic audio still proves the STT endpoint is wired; report clearly. + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok STT returned %d: %s", resp.StatusCode, string(respBody))) + } + text := strings.TrimSpace(gjson.GetBytes(respBody, "text").String()) + if text == "" { + text = strings.TrimSpace(string(respBody)) + if len(text) > 200 { + text = text[:200] + "..." + } + } + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("stt ok: %s\n", text)}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +// testGrokRealtime dials the standalone xAI Voice Realtime WebSocket +// (wss://api.x.ai/v1/realtime?model=...) to verify auth + endpoint reachability. +// It does not run a full audio session — success is WS handshake, optionally +// enriched with the first server event type when one arrives quickly. +func (s *AccountTestService) testGrokRealtime(c *gin.Context, ctx context.Context, account *Account, authToken, modelID string) error { + model := strings.TrimSpace(modelID) + if model == "" { + model = defaultGrokRealtimeTestModel + } + if mapped := strings.TrimSpace(account.GetMappedModel(model)); mapped != "" { + model = mapped + } + + base, err := buildGrokVoiceURL(account, s.cfg, "realtime") + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok Realtime URL: %s", err.Error())) + } + u, err := url.Parse(base) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Grok Realtime URL: %s", err.Error())) + } + switch strings.ToLower(u.Scheme) { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + case "wss", "ws": + // already websocket + default: + return s.sendErrorAndEnd(c, "Invalid Grok Realtime URL scheme") + } + q := u.Query() + if q.Get("model") == "" { + q.Set("model", model) + } + u.RawQuery = q.Encode() + wsURL := u.String() + + s.prepareGrokTestSSE(c) + s.sendEvent(c, TestEvent{Type: "test_start", Model: model}) + s.sendEvent(c, TestEvent{Type: "status", Text: "Dialing standalone wss /v1/realtime (connectivity probe)..."}) + s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("realtime target: %s\n", redactGrokRealtimeURLForLog(wsURL))}) + + headers := http.Header{} + headers.Set("Authorization", "Bearer "+authToken) + if account.IsGrokOAuth() { + applyGrokCLIHeaders(headers) + } + account.ApplyHeaderOverrides(headers) + + dialer := s.grokWSDialer + if dialer == nil { + dialer = newDefaultOpenAIWSClientDialer() + } + + dialCtx, cancel := context.WithTimeout(ctx, grokRealtimeProbeTimeout) + defer cancel() + + conn, status, _, dialErr := dialer.Dial(dialCtx, wsURL, headers, s.grokTestProxyURL(account)) + if dialErr != nil { + detail := dialErr.Error() + var hs *openAIWSHandshakeError + if errors.As(dialErr, &hs) && len(hs.Body) > 0 { + body := strings.TrimSpace(string(hs.Body)) + if len(body) > 300 { + body = body[:300] + "..." + } + detail = fmt.Sprintf("%s body=%s", detail, body) + } + if status > 0 { + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Realtime WS handshake failed (HTTP %d): %s", status, detail)) + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Realtime WS dial failed: %s", detail)) + } + defer func() { _ = conn.Close() }() + + s.sendEvent(c, TestEvent{Type: "content", Text: "realtime ws handshake ok\n"}) + + // Best-effort: read one server event if it arrives quickly (session.created etc.). + // Handshake alone is enough for connectivity; missing first event is not a failure. + readCtx, readCancel := context.WithTimeout(ctx, 3*time.Second) + defer readCancel() + if msg, readErr := conn.ReadMessage(readCtx); readErr == nil && len(msg) > 0 { + eventType := strings.TrimSpace(gjson.GetBytes(msg, "type").String()) + if eventType == "" { + eventType = "unknown" + } + preview := strings.TrimSpace(string(msg)) + if len(preview) > 240 { + preview = preview[:240] + "..." + } + s.sendEvent(c, TestEvent{ + Type: "content", + Text: fmt.Sprintf("realtime first event: type=%s payload=%s\n", eventType, preview), + }) + } else { + s.sendEvent(c, TestEvent{ + Type: "content", + Text: "realtime handshake succeeded (no server event within 3s; still connectivity OK)\n", + }) + } + + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +// validateAccountTestDataURL ensures data URLs are well-formed and size-bounded. +func validateAccountTestDataURL(raw, requiredPrefix string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("media data URL is empty") + } + if !strings.HasPrefix(raw, "data:") { + return fmt.Errorf("media must be a data: URL (data:;base64,...)") + } + // Rough size check before decode (base64 expands ~4/3). + if len(raw) > maxAccountTestMediaBytes*2 { + return fmt.Errorf("media data URL exceeds size limit") + } + _, mime, err := decodeAccountTestDataURL(raw) + if err != nil { + return err + } + if requiredPrefix != "" && !strings.HasPrefix(strings.ToLower(mime), strings.ToLower(requiredPrefix)) { + return fmt.Errorf("expected media type prefix %q, got %q", requiredPrefix, mime) + } + return nil +} + +// normalizeAccountTestImageDataURL validates an image data URL, enforces xAI +// minimum dimensions (8x8), and rewrites to a clean data:image/;base64,... form. +func normalizeAccountTestImageDataURL(raw string) (string, error) { + if err := validateAccountTestDataURL(raw, "image/"); err != nil { + return "", err + } + data, mime, err := decodeAccountTestDataURL(raw) + if err != nil { + return "", err + } + // Soft cap decoded bytes (~4 MiB) for edit payloads to avoid upstream/proxy EOF. + const maxDecodedImage = 4 << 20 + if len(data) > maxDecodedImage { + return "", fmt.Errorf( + "source image is too large (%d bytes decoded). Please use a smaller image (under ~4 MB) for admin edit tests", + len(data), + ) + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + // Keep raw data URL if decoder does not understand the codec (e.g. webp + // without golang.org/x/image/webp); still send upstream and let xAI validate. + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil + } + if cfg.Width < 8 || cfg.Height < 8 { + return "", fmt.Errorf( + "source image is too small (%dx%d). xAI requires both width and height to be at least 8 pixels", + cfg.Width, cfg.Height, + ) + } + // Prefer a stable mime from config when known. + if mime == "" || mime == "application/octet-stream" { + mime = "image/png" + } + return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil +} + +func isTransientGrokTransportError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unexpected eof") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "broken pipe") || + strings.Contains(msg, "i/o timeout") || + strings.Contains(msg, "timeout awaiting response") +} + +func formatGrokImageTransportError(err error, hasSourceImage bool, payloadBytes int) string { + base := fmt.Sprintf("Grok image request failed: %s", err.Error()) + if !hasSourceImage { + return base + } + return base + fmt.Sprintf( + " (edit payload ~%d bytes). Tips: use a smaller source image (<4 MB / lower resolution), ensure the account proxy is stable, and retry. xAI /images/edits expects image as {\"url\":\"data:image/...;base64,...\",\"type\":\"image_url\"}.", + payloadBytes, + ) +} + +func formatGrokImagesAPIError(status int, body []byte, hasSourceImage bool) string { + msg := strings.TrimSpace(string(body)) + if len(msg) > 800 { + msg = msg[:800] + "..." + } + prefix := fmt.Sprintf("Grok images API returned %d: %s", status, msg) + lower := strings.ToLower(msg) + if hasSourceImage && (strings.Contains(lower, "too small") || strings.Contains(lower, "at least 8")) { + return prefix + " — upload a source image with both width and height ≥ 8 px." + } + return prefix +} + +func decodeAccountTestDataURL(raw string) (data []byte, mime string, err error) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(raw, "data:") { + return nil, "", fmt.Errorf("not a data URL") + } + rest := strings.TrimPrefix(raw, "data:") + comma := strings.Index(rest, ",") + if comma < 0 { + return nil, "", fmt.Errorf("invalid data URL (missing comma)") + } + meta := rest[:comma] + payload := rest[comma+1:] + mime = "application/octet-stream" + if semi := strings.Index(meta, ";"); semi >= 0 { + if t := strings.TrimSpace(meta[:semi]); t != "" { + mime = t + } + } else if t := strings.TrimSpace(meta); t != "" { + mime = t + } + if !strings.Contains(strings.ToLower(meta), ";base64") { + return nil, "", fmt.Errorf("only base64 data URLs are supported") + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + // Some browsers emit URL-safe base64 without padding. + decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(payload, "=")) + if err != nil { + return nil, "", fmt.Errorf("base64 decode failed: %w", err) + } + } + if len(decoded) == 0 { + return nil, "", fmt.Errorf("decoded media is empty") + } + if len(decoded) > maxAccountTestMediaBytes { + return nil, "", fmt.Errorf("media exceeds %d byte limit", maxAccountTestMediaBytes) + } + return decoded, mime, nil +} + +func sttFilenameForMIME(mime string) string { + switch strings.ToLower(strings.TrimSpace(mime)) { + case "audio/mpeg", "audio/mp3": + return "upload.mp3" + case "audio/wav", "audio/x-wav", "audio/wave": + return "upload.wav" + case "audio/webm": + return "upload.webm" + case "audio/ogg", "audio/opus": + return "upload.ogg" + case "audio/mp4", "audio/m4a", "audio/x-m4a": + return "upload.m4a" + default: + return "upload.bin" + } +} + +// redactGrokRealtimeURLForLog strips query secrets while keeping model for diagnostics. +func redactGrokRealtimeURLForLog(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil { + return raw + } + // Keep model query only. + model := u.Query().Get("model") + u.RawQuery = "" + if model != "" { + u.RawQuery = "model=" + url.QueryEscape(model) + } + // Never log bearer in fragment/userinfo. + u.User = nil + u.Fragment = "" + return u.String() +} + +// minimalSilentWAV returns a valid tiny mono 8kHz 16-bit PCM WAV (~0.05s silence). +func minimalSilentWAV() []byte { + // 400 samples * 2 bytes = 800 data bytes + const sampleRate = 8000 + const numSamples = 400 + dataSize := numSamples * 2 + buf := make([]byte, 44+dataSize) + copy(buf[0:], []byte("RIFF")) + binary.LittleEndian.PutUint32(buf[4:], uint32(36+dataSize)) + copy(buf[8:], []byte("WAVE")) + copy(buf[12:], []byte("fmt ")) + binary.LittleEndian.PutUint32(buf[16:], 16) // PCM chunk size + binary.LittleEndian.PutUint16(buf[20:], 1) // PCM + binary.LittleEndian.PutUint16(buf[22:], 1) // mono + binary.LittleEndian.PutUint32(buf[24:], sampleRate) + binary.LittleEndian.PutUint32(buf[28:], sampleRate*2) // byte rate + binary.LittleEndian.PutUint16(buf[32:], 2) // block align + binary.LittleEndian.PutUint16(buf[34:], 16) // bits + copy(buf[36:], []byte("data")) + binary.LittleEndian.PutUint32(buf[40:], uint32(dataSize)) + // samples already zero (silence) + return buf +} + // testOpenAIChatCompletionsConnection tests an OpenAI-compatible APIKey account // through the raw /v1/chat/completions endpoint. func (s *AccountTestService) testOpenAIChatCompletionsConnection( @@ -1209,8 +2297,10 @@ func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, accou } // Use streamGenerateContent for real-time feedback - fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", - strings.TrimRight(normalizedBaseURL, "/"), modelID) + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload)) if err != nil { @@ -1246,7 +2336,10 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun if err != nil { return nil, err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(normalizedBaseURL, "/"), modelID) + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true) + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload)) if err != nil { @@ -1854,15 +2947,16 @@ 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", "codex_cli_rs") + req.Header.Set("originator", openai.CodexDefaultOriginator) if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" { req.Header.Set("User-Agent", customUA) } else { req.Header.Set("User-Agent", codexCLIUserAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) - // 与真实转发一致:originator 与最终 User-Agent 首段配套(原 opencode 与 Codex UA 错配会 404,issue #3901)。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 + // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/account_test_service_grok_test.go b/backend/internal/service/account_test_service_grok_test.go index fcfe1257b6..781e29e47d 100644 --- a/backend/internal/service/account_test_service_grok_test.go +++ b/backend/internal/service/account_test_service_grok_test.go @@ -3,7 +3,13 @@ package service import ( + "bytes" "context" + "encoding/base64" + "errors" + "image" + "image/color" + "image/png" "io" "net/http" "net/http/httptest" @@ -22,6 +28,60 @@ type grokAccountTestRateLimitRepo struct { resetAt time.Time } +func TestObserveGrokTestResponseClassifiesBodyOnlyQuotaErrors(t *testing.T) { + account := &Account{ID: 1901, Platform: PlatformGrok, Type: AccountTypeOAuth} + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + svc := &AccountTestService{accountRepo: repo} + + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"subscription:free-usage-exhausted","message":"included free usage exhausted"}}`)), + } + svc.observeGrokTestResponse(context.Background(), account, resp) + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok free usage exhausted", repo.lastTempUnschedReason) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "free-usage-exhausted") +} + +func TestObserveGrokTestResponseDoesNotQuarantineContentPolicy(t *testing.T) { + account := &Account{ID: 1902, Platform: PlatformGrok, Type: AccountTypeOAuth} + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + svc := &AccountTestService{accountRepo: repo} + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)), + } + svc.observeGrokTestResponse(context.Background(), account, resp) + require.Zero(t, repo.tempUnschedCalls) + require.Zero(t, repo.rateLimitedCalls) +} + +func TestObserveGrokTestResponseKeepsEntitlement403Cooldown(t *testing.T) { + account := &Account{ID: 1903, Platform: PlatformGrok, Type: AccountTypeOAuth} + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + svc := &AccountTestService{accountRepo: repo} + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"subscription required"}}`)), + } + before := time.Now() + svc.observeGrokTestResponse(context.Background(), account, resp) + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok entitlement or subscription tier denied", repo.lastTempUnschedReason) + require.Greater(t, repo.lastTempUnschedUntil, before.Add(29*time.Minute)) +} + func (r *grokAccountTestRateLimitRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error { r.rateLimitedCalls++ r.resetAt = resetAt @@ -199,3 +259,386 @@ func TestAccountTestService_Grok429WithoutQuotaHeadersUsesFallback(t *testing.T) require.Equal(t, 1, repo.rateLimitedCalls) require.WithinDuration(t, before.Add(grokRateLimitFallbackCooldown), repo.resetAt, time.Second) } + +func TestAccountTestService_GrokImageModelUsesImagesGenerations(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 17, Name: "grok-oauth-image", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + "base_url": "https://cli-chat-proxy.grok.com/v1", + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"data":[{"b64_json":"QUJD","mime_type":"image/jpeg"}]}`, + )), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/17/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image", "a red apple", AccountTestModeDefault) + + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/images/generations", upstream.lastReq.URL.String()) + require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "a red apple", gjson.GetBytes(upstream.lastBody, "prompt").String()) + require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String()) + require.Contains(t, rec.Body.String(), `"type":"image"`) + require.Contains(t, rec.Body.String(), "data:image/jpeg;base64,QUJD") + require.Contains(t, rec.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_GrokWebSearchModeUsesResponsesWebSearchTool(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 18, Name: "grok-oauth-search", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"id":"r1","output":[{"type":"web_search_call","id":"ws1","status":"completed"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Grok is built by xAI."}]}]}`, + )), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/18/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "grok-4.5", "xAI Grok", AccountTestModeGrokSearch) + + require.NoError(t, err) + require.Equal(t, "https://cli-chat-proxy.grok.com/v1/responses", upstream.lastReq.URL.String()) + // Standalone web_search wraps the query in the gateway-style prompt. + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input").String(), "xAI Grok") + require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String()) + require.Equal(t, "web_search_call.action.sources", gjson.GetBytes(upstream.lastBody, "include.0").String()) + require.Contains(t, rec.Body.String(), "web_search ok") + require.Contains(t, rec.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_GrokTTSIncludesLanguage(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 19, Name: "grok-oauth-tts", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"audio/mpeg"}}, + Body: io.NopCloser(strings.NewReader("ID3fakeaudio")), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/19/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "", "hello voice", AccountTestModeGrokTTS) + + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/tts", upstream.lastReq.URL.String()) + require.Equal(t, "hello voice", gjson.GetBytes(upstream.lastBody, "text").String()) + require.Equal(t, "en", gjson.GetBytes(upstream.lastBody, "language").String()) + require.Contains(t, rec.Body.String(), "tts ok") + require.Contains(t, rec.Body.String(), `"type":"audio"`) + require.Contains(t, rec.Body.String(), "data:audio/mpeg;base64,") + require.Contains(t, rec.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_GrokImageEditUsesUploadedImage(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 24, Name: "grok-oauth-image-edit", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"data":[{"b64_json":"QUJD","mime_type":"image/png"}]}`, + )), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/24/test", nil) + + // 8x8 solid PNG (xAI min dimension is 8px). + src := minimalAccountTestPNGDataURL(8, 8) + err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image", "edit me", AccountTestModeGrokImage, AccountTestOptions{ + ImageDataURL: src, + }) + + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/images/edits", upstream.lastReq.URL.String()) + require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "image.url").String(), "data:image/png;base64,")) + require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "image.type").String()) + require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String()) + // concrete image model ids pass through; only bare "grok-imagine" is aliased. + require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Contains(t, rec.Body.String(), `"type":"image"`) +} + +func TestAccountTestService_GrokImageEditRejectsTinySource(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 25, Name: "grok-oauth-image-tiny", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: &httpUpstreamRecorder{}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/25/test", nil) + + // 1x1 PNG data URL + tiny := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image-quality", "edit", AccountTestModeGrokImage, AccountTestOptions{ + ImageDataURL: tiny, + }) + require.Error(t, err) + require.Contains(t, rec.Body.String(), "too small") +} + +// minimalAccountTestPNGDataURL builds a solid RGBA PNG as a data URL for tests. +func minimalAccountTestPNGDataURL(w, h int) string { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 200, G: 40, B: 40, A: 255}) + } + } + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func TestAccountTestService_GrokExplicitImageModeDefaultsModel(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 20, Name: "grok-oauth-image-mode", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"data":[{"b64_json":"QUJD","mime_type":"image/jpeg"}]}`, + )), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/20/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokImage) + + require.NoError(t, err) + require.Equal(t, "https://api.x.ai/v1/images/generations", upstream.lastReq.URL.String()) + require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String()) + require.Contains(t, rec.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_GrokVideoUpstreamErrorIsNotMaskedAsSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 21, Name: "grok-oauth-video-err", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"code":"invalid-argument","error":"bad video request"}`, + )), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/21/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "grok-imagine-video", "bounce ball", AccountTestModeGrokVideo) + + require.Error(t, err) + require.Equal(t, "https://api.x.ai/v1/videos/generations", upstream.lastReq.URL.String()) + require.Contains(t, rec.Body.String(), `"type":"error"`) + require.Contains(t, rec.Body.String(), "Grok videos API returned 400") + require.NotContains(t, rec.Body.String(), `"success":true`) +} + +type grokRealtimeTestConn struct { + msg []byte +} + +func (c *grokRealtimeTestConn) WriteJSON(context.Context, any) error { return nil } +func (c *grokRealtimeTestConn) ReadMessage(context.Context) ([]byte, error) { + if c == nil || len(c.msg) == 0 { + return nil, context.DeadlineExceeded + } + return c.msg, nil +} +func (c *grokRealtimeTestConn) Ping(context.Context) error { return nil } +func (c *grokRealtimeTestConn) Close() error { return nil } + +type grokRealtimeTestDialer struct { + lastURL string + lastAuth string + lastProxy string + conn openAIWSClientConn + err error + status int +} + +func (d *grokRealtimeTestDialer) Dial(_ context.Context, wsURL string, headers http.Header, proxyURL string) (openAIWSClientConn, int, http.Header, error) { + d.lastURL = wsURL + d.lastAuth = headers.Get("Authorization") + d.lastProxy = proxyURL + if d.err != nil { + return nil, d.status, nil, d.err + } + if d.conn == nil { + d.conn = &grokRealtimeTestConn{} + } + return d.conn, 0, nil, nil +} + +func TestAccountTestService_GrokRealtimeModeDialsWS(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 22, Name: "grok-oauth-realtime", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + dialer := &grokRealtimeTestDialer{ + conn: &grokRealtimeTestConn{msg: []byte(`{"type":"session.created","session":{"id":"sess_1"}}`)}, + } + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + grokWSDialer: dialer, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/22/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokRealtime) + + require.NoError(t, err) + require.Contains(t, dialer.lastURL, "wss://api.x.ai/v1/realtime") + require.Contains(t, dialer.lastURL, "model=grok-voice-latest") + require.Equal(t, "Bearer grok-access-token", dialer.lastAuth) + require.Contains(t, rec.Body.String(), "realtime ws handshake ok") + require.Contains(t, rec.Body.String(), "session.created") + require.Contains(t, rec.Body.String(), `"type":"test_complete"`) + require.Contains(t, rec.Body.String(), `"success":true`) +} + +func TestAccountTestService_GrokRealtimeModeDialFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &Account{ + ID: 23, Name: "grok-oauth-realtime-fail", Platform: PlatformGrok, + Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "grok-access-token", + "refresh_token": "grok-refresh-token", + "expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}} + dialer := &grokRealtimeTestDialer{ + status: 401, + err: &openAIWSHandshakeError{Body: []byte(`{"error":"unauthorized"}`), Err: errors.New("websocket handshake failed")}, + } + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + grokWSDialer: dialer, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/23/test", nil) + + err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokRealtime) + + require.Error(t, err) + require.Contains(t, rec.Body.String(), `"type":"error"`) + require.Contains(t, rec.Body.String(), "Realtime") +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 29f2580585..084d153ff2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -113,7 +113,7 @@ const ( openAIProbeCacheTTL = 10 * time.Minute grokProbeRetryTTL = 1 * time.Minute grokFreeQuotaWindow = 24 * time.Hour - openAICodexProbeVersion = "0.144.1" + openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移 ) // UsageCache 封装账户使用量相关的缓存 @@ -199,20 +199,22 @@ type UsageInfo struct { AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"` // Grok / xAI 被动额度快照 - GrokRequestQuota *xai.QuotaWindow `json:"grok_request_quota,omitempty"` - GrokTokenQuota *xai.QuotaWindow `json:"grok_token_quota,omitempty"` - GrokRetryAfterSeconds *int `json:"grok_retry_after_seconds,omitempty"` - GrokEntitlementStatus string `json:"grok_entitlement_status,omitempty"` - GrokQuotaSnapshotState string `json:"grok_quota_snapshot_state,omitempty"` - GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"` - GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"` - GrokLastStatusCode int `json:"grok_last_status_code,omitempty"` - GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"` - GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"` - GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` - GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` - GrokLocalUsageMonthly *WindowStats `json:"grok_local_usage_monthly,omitempty"` - GrokBilling *xai.BillingSummary `json:"grok_billing,omitempty"` + GrokRequestQuota *xai.QuotaWindow `json:"grok_request_quota,omitempty"` + GrokTokenQuota *xai.QuotaWindow `json:"grok_token_quota,omitempty"` + GrokRetryAfterSeconds *int `json:"grok_retry_after_seconds,omitempty"` + GrokEntitlementStatus string `json:"grok_entitlement_status,omitempty"` + GrokQuotaSnapshotState string `json:"grok_quota_snapshot_state,omitempty"` + GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"` + GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"` + GrokLastStatusCode int `json:"grok_last_status_code,omitempty"` + GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"` + GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"` + GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` + GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` + GrokLocalUsageMonthly *WindowStats `json:"grok_local_usage_monthly,omitempty"` + // ThirtyDay is the official monthly billing window (used/monthlyLimit %). + ThirtyDay *UsageProgress `json:"thirty_day,omitempty"` + GrokBilling *xai.BillingSummary `json:"grok_billing,omitempty"` // Antigravity 账号级信息 SubscriptionTier string `json:"subscription_tier,omitempty"` // 归一化订阅等级: FREE/PRO/ULTRA/UNKNOWN @@ -333,23 +335,28 @@ func NewAccountUsageService( } } -// GetUsage 获取账号使用量 -// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟 -// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope) -// API Key账号: 不支持usage查询 -func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { - forceProbe := len(force) > 0 && force[0] +func supportsAnthropicPassiveUsage(account *Account) bool { + return account != nil && account.IsAnthropicOAuthOrSetupToken() +} - account, err := s.accountRepo.GetByID(ctx, accountID) - if err != nil { - return nil, fmt.Errorf("get account failed: %w", err) +func batchUsageErrorMessage(err error) string { + if err == nil { + return "" } + return err.Error() +} + +func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *Account, forceProbe bool) (*UsageInfo, error) { + if account == nil { + return nil, fmt.Errorf("account is required") + } + accountID := account.ID // Dedicated UI load-test accounts must remain fully interactive without ever // contacting Anthropic with synthetic credentials. Reuse the same persisted // passive snapshot that the account table loads on mount. if account.IsSyntheticUITest() && account.IsAnthropicOAuthOrSetupToken() { - return s.GetPassiveUsage(ctx, accountID) + return s.getPassiveUsageForAccount(ctx, account) } if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth { @@ -482,6 +489,96 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for return nil, fmt.Errorf("account type %s does not support usage query", account.Type) } +// GetUsage 获取账号使用量 +// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟 +// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope) +// API Key账号: 不支持usage查询 +func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { + forceProbe := len(force) > 0 && force[0] + + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account failed: %w", err) + } + + return s.getUsageForAccount(ctx, account, forceProbe) +} + +// GetUsageBatch 批量获取账号使用量。 +// Anthropic OAuth/SetupToken 统一走 passive 链路,其他账号复用现有主动查询逻辑。 +// 单个账号失败不会中断整批请求,错误会按账号返回。 +func (s *AccountUsageService) GetUsageBatch(ctx context.Context, accountIDs []int64, force bool) (map[int64]*UsageInfo, map[int64]string, error) { + uniqueIDs := make([]int64, 0, len(accountIDs)) + seen := make(map[int64]struct{}, len(accountIDs)) + for _, accountID := range accountIDs { + if accountID <= 0 { + continue + } + if _, ok := seen[accountID]; ok { + continue + } + seen[accountID] = struct{}{} + uniqueIDs = append(uniqueIDs, accountID) + } + + usageByAccount := make(map[int64]*UsageInfo, len(uniqueIDs)) + errorsByAccount := make(map[int64]string) + if len(uniqueIDs) == 0 { + return usageByAccount, errorsByAccount, nil + } + + accounts, err := s.accountRepo.GetByIDs(ctx, uniqueIDs) + if err != nil { + return nil, nil, fmt.Errorf("get accounts failed: %w", err) + } + + accountsByID := make(map[int64]*Account, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + accountsByID[account.ID] = account + } + + var mu sync.Mutex + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(6) + + for _, accountID := range uniqueIDs { + id := accountID + account := accountsByID[id] + if account == nil { + errorsByAccount[id] = ErrAccountNotFound.Error() + continue + } + + g.Go(func() error { + var usage *UsageInfo + var usageErr error + if supportsAnthropicPassiveUsage(account) { + usage, usageErr = s.getPassiveUsageForAccount(gctx, account) + } else { + usage, usageErr = s.getUsageForAccount(gctx, account, force) + } + + mu.Lock() + defer mu.Unlock() + if usageErr != nil { + errorsByAccount[id] = batchUsageErrorMessage(usageErr) + return nil + } + usageByAccount[id] = usage + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, nil, err + } + + return usageByAccount, errorsByAccount, nil +} + // GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。 // 仅适用于 Anthropic OAuth / SetupToken 账号。 func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { @@ -490,7 +587,11 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int return nil, fmt.Errorf("get account failed: %w", err) } - if !account.IsAnthropicOAuthOrSetupToken() { + return s.getPassiveUsageForAccount(ctx, account) +} + +func (s *AccountUsageService) getPassiveUsageForAccount(ctx context.Context, account *Account) (*UsageInfo, error) { + if !supportsAnthropicPassiveUsage(account) { return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts") } @@ -759,7 +860,7 @@ 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", "codex_cli_rs") + req.Header.Set("Originator", openaipkg.CodexDefaultOriginator) req.Header.Set("Version", openAICodexProbeVersion) req.Header.Set("User-Agent", codexCLIUserAgent) if s.identityCache != nil { @@ -767,9 +868,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) } } - // 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存,如 codex-tui)首段配套, - // 否则探针被上游 404(issue #3901)。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。 + // 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); + // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 + enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent()) setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" @@ -1024,6 +1126,13 @@ func (s *AccountUsageService) getGrokUsage(ctx context.Context, account *Account ctx, s.usageLogRepo, account.ID, usage.GrokBilling, time.Now().UTC(), ) } + // Attach local window stats to official 7d/30d progress bars. + if usage.SevenDay != nil && usage.GrokLocalUsage7d != nil { + usage.SevenDay.WindowStats = usage.GrokLocalUsage7d + } + if usage.ThirtyDay != nil && usage.GrokLocalUsageMonthly != nil { + usage.ThirtyDay.WindowStats = usage.GrokLocalUsageMonthly + } } enrichUsageWithAccountError(usage, account) diff --git a/backend/internal/service/account_usage_service_batch_test.go b/backend/internal/service/account_usage_service_batch_test.go new file mode 100644 index 0000000000..067bf0ddd1 --- /dev/null +++ b/backend/internal/service/account_usage_service_batch_test.go @@ -0,0 +1,191 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" +) + +// Minimal UsageLogRepository stub for batch usage tests (HEAD lacks geminiUsageLogRepoStub). +type usageBatchLogRepoStub struct{} + +var _ UsageLogRepository = (*usageBatchLogRepoStub)(nil) + +func (r *usageBatchLogRepoStub) Create(context.Context, *UsageLog) (bool, error) { + return false, nil +} +func (r *usageBatchLogRepoStub) GetByID(context.Context, int64) (*UsageLog, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) Delete(context.Context, int64) error { return nil } +func (r *usageBatchLogRepoStub) ListByUser(context.Context, int64, pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByAPIKey(context.Context, int64, pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByAccount(context.Context, int64, pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByUserAndTimeRange(context.Context, int64, time.Time, time.Time) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByAPIKeyAndTimeRange(context.Context, int64, time.Time, time.Time) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByAccountAndTimeRange(context.Context, int64, time.Time, time.Time) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) ListByModelAndTimeRange(context.Context, string, time.Time, time.Time) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) GetAccountWindowStats(context.Context, int64, time.Time) (*usagestats.AccountStats, error) { + return &usagestats.AccountStats{}, nil +} +func (r *usageBatchLogRepoStub) GetAccountTodayStats(context.Context, int64) (*usagestats.AccountStats, error) { + return &usagestats.AccountStats{}, nil +} +func (r *usageBatchLogRepoStub) GetDashboardStats(context.Context) (*usagestats.DashboardStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUsageTrendWithFilters(context.Context, time.Time, time.Time, string, int64, int64, int64, int64, string, *int16, *bool, *int8) ([]usagestats.TrendDataPoint, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetModelStatsWithFilters(context.Context, time.Time, time.Time, int64, int64, int64, int64, *int16, *bool, *int8) ([]usagestats.ModelStat, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetEndpointStatsWithFilters(context.Context, time.Time, time.Time, int64, int64, int64, int64, string, *int16, *bool, *int8) ([]usagestats.EndpointStat, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUpstreamEndpointStatsWithFilters(context.Context, time.Time, time.Time, int64, int64, int64, int64, string, *int16, *bool, *int8) ([]usagestats.EndpointStat, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetGroupStatsWithFilters(context.Context, time.Time, time.Time, int64, int64, int64, int64, *int16, *bool, *int8) ([]usagestats.GroupStat, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserBreakdownStats(context.Context, time.Time, time.Time, usagestats.UserBreakdownDimension, int) ([]usagestats.UserBreakdownItem, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAllGroupUsageSummary(context.Context, time.Time) ([]usagestats.GroupUsageSummary, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAPIKeyUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.APIKeyUsageTrendPoint, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.UserUsageTrendPoint, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserSpendingRanking(context.Context, time.Time, time.Time, int) (*usagestats.UserSpendingRankingResponse, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetBatchUserUsageStats(context.Context, []int64, time.Time, time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetBatchAPIKeyUsageStats(context.Context, []int64, time.Time, time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserDashboardStats(context.Context, int64) (*usagestats.UserDashboardStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAPIKeyDashboardStats(context.Context, int64) (*usagestats.UserDashboardStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserUsageTrendByUserID(context.Context, int64, time.Time, time.Time, string) ([]usagestats.TrendDataPoint, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserModelStats(context.Context, int64, time.Time, time.Time) ([]usagestats.ModelStat, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (r *usageBatchLogRepoStub) GetGlobalStats(context.Context, time.Time, time.Time) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetStatsWithFilters(context.Context, usagestats.UsageLogFilters) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAccountUsageStats(context.Context, int64, time.Time, time.Time) (*usagestats.AccountUsageStatsResponse, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetUserStatsAggregated(context.Context, int64, time.Time, time.Time) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAPIKeyStatsAggregated(context.Context, int64, time.Time, time.Time) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetAccountStatsAggregated(context.Context, int64, time.Time, time.Time) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetModelStatsAggregated(context.Context, string, time.Time, time.Time) (*usagestats.UsageStats, error) { + return nil, nil +} +func (r *usageBatchLogRepoStub) GetDailyStatsAggregated(context.Context, int64, time.Time, time.Time) ([]map[string]any, error) { + return nil, nil +} + +func TestAccountUsageService_GetUsageBatch_BestEffortByAccount(t *testing.T) { + t.Parallel() + + resetAt := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) + + repo := &stubOpenAIAccountRepo{ + accounts: []Account{ + { + ID: 7001, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "passive_usage_7d_utilization": 0.62, + }, + }, + { + ID: 7002, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_usage_updated_at": time.Now().UTC().Format(time.RFC3339), + "codex_5h_used_percent": 18.0, + "codex_5h_reset_at": resetAt.Format(time.RFC3339), + "codex_7d_used_percent": 34.0, + "codex_7d_reset_at": resetAt.Add(24 * time.Hour).Format(time.RFC3339), + "workspace_id": "org-test", + "chatgpt_account_id": "acct-test", + "openai_snapshot_version": "test", + }, + }, + { + ID: 7003, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + }, + }, + } + + svc := &AccountUsageService{ + accountRepo: repo, + usageLogRepo: &usageBatchLogRepoStub{}, + cache: NewUsageCache(), + } + + usageByAccount, errorsByAccount, err := svc.GetUsageBatch(context.Background(), []int64{7001, 7002, 7003, 7002}, false) + if err != nil { + t.Fatalf("GetUsageBatch() error = %v", err) + } + + if usageByAccount[7001] == nil || usageByAccount[7001].Source != "passive" { + t.Fatalf("expected anthropic passive usage, got %#v", usageByAccount[7001]) + } + + if usageByAccount[7002] == nil || usageByAccount[7002].FiveHour == nil || usageByAccount[7002].FiveHour.Utilization != 18.0 { + t.Fatalf("expected openai snapshot usage, got %#v", usageByAccount[7002]) + } + + if !strings.Contains(strings.ToLower(errorsByAccount[7003]), "does not support usage query") { + t.Fatalf("expected API key account error to be preserved, got %q", errorsByAccount[7003]) + } +} diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go index 6ce804bb16..15c5a66fa1 100644 --- a/backend/internal/service/account_wildcard_test.go +++ b/backend/internal/service/account_wildcard_test.go @@ -6,8 +6,31 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) +func TestGrokAccountModelMappingCacheInvalidatesWithRuntimeSettings(t *testing.T) { + original := xai.RuntimeModelMappingOptions() + t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) }) + account := &Account{Platform: PlatformGrok, Credentials: map[string]any{}} + + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{}) + requireMappedModel(t, account, "claude-sonnet-4-5", "claude-sonnet-4-5") + + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{ + DefaultText: "grok-build-0.1", + EnableCrossClientMap: true, + }) + requireMappedModel(t, account, "claude-sonnet-4-5", "grok-build-0.1") +} + +func requireMappedModel(t *testing.T, account *Account, requested, expected string) { + t.Helper() + if actual := account.GetMappedModel(requested); actual != expected { + t.Fatalf("GetMappedModel(%q) = %q, want %q", requested, actual, expected) + } +} + func TestMatchWildcard(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 1238452ed1..9958846658 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -394,67 +394,12 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat return normalized, nil } -// ValidateGrokMediaEligibilityExtra validates the optional media-routing -// override. null removes the override and returns the account to automatic -// provider-observation based routing. -func ValidateGrokMediaEligibilityExtra(platform string, extra map[string]any) error { - if platform != PlatformGrok || extra == nil { - return nil - } - raw, exists := extra[GrokMediaEligibleExtraKey] - if !exists || raw == nil { - return nil - } - if _, ok := raw.(bool); !ok { - return infraerrors.BadRequest( - "GROK_MEDIA_ELIGIBILITY_INVALID", - "grok_media_eligible must be a boolean or null", - ) - } - return nil -} - -func normalizeGrokMediaEligibilityExtra(platform string, extra map[string]any) (map[string]any, error) { - if platform != PlatformGrok { - return extra, nil - } - if err := ValidateGrokMediaEligibilityExtra(platform, extra); err != nil { - return nil, err - } - normalized := maps.Clone(extra) - if normalized != nil && normalized[GrokMediaEligibleExtraKey] == nil { - delete(normalized, GrokMediaEligibleExtraKey) - } - return normalized, nil -} - -func normalizeGrokMediaEligibilityUpdateExtra(account *Account, input *UpdateAccountInput, normalized map[string]any) (map[string]any, error) { - if account == nil || account.Platform != PlatformGrok { - return normalized, nil - } - if err := ValidateGrokMediaEligibilityExtra(account.Platform, input.Extra); err != nil { - return nil, err - } - normalized = maps.Clone(normalized) - if normalized == nil { - normalized = make(map[string]any) - } - raw, provided := input.Extra[GrokMediaEligibleExtraKey] - if provided { - if raw == nil { - delete(normalized, GrokMediaEligibleExtraKey) - } - return normalized, nil - } - if current, ok := account.Extra[GrokMediaEligibleExtraKey].(bool); ok { - normalized[GrokMediaEligibleExtraKey] = current - } - return normalized, nil -} +// Grok media eligibility helpers live in account_grok_media_eligibility.go. func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) { // Probe/session state is system-managed. New accounts always start with automatic refresh disabled. delete(accountExtra, UpstreamBillingProbeEnabledExtraKey) + delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey) delete(accountExtra, UpstreamBillingProbeExtraKey) delete(accountExtra, OllamaCloudUsageSessionExtraKey) delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey) @@ -550,6 +495,8 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + // Never persist ephemeral SSO/password secrets after OAuth conversion. + input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials) account, err := buildAccountForCreate(input, accountExtra) if err != nil { @@ -594,10 +541,6 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou return account, nil } -type accountProbeEnabledAtomicUpdater interface { - UpdateWithUpstreamBillingProbeEnabled(context.Context, *Account, bool) error -} - func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { @@ -664,10 +607,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { return nil, err } + // Strip SSO/password residue that must never sit next to OAuth tokens. + account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials) } // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 - var requestedProbeEnabledUpdate *bool + requestedProbeEnabledUpdate := input.ProbeEnabled + requestedRateSyncEnabledUpdate := input.RateSyncEnabled if input.Extra != nil { requestedProbeEnabled, hasRequestedProbeEnabled := normalizedExtra[UpstreamBillingProbeEnabledExtraKey] if hasRequestedProbeEnabled { @@ -675,9 +621,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if !ok { return nil, infraerrors.BadRequest("INVALID_UPSTREAM_BILLING_PROBE_ENABLED", "upstream_billing_probe_enabled must be a boolean") } + if requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate != enabled { + return nil, infraerrors.BadRequest("CONFLICTING_UPSTREAM_BILLING_PROBE_ENABLED", "conflicting upstream_billing_probe_enabled values") + } requestedProbeEnabledUpdate = &enabled } delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey) + delete(normalizedExtra, UpstreamBillingRateSyncEnabledExtraKey) delete(normalizedExtra, UpstreamBillingProbeExtraKey) delete(normalizedExtra, OllamaCloudUsageSessionExtraKey) delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey) @@ -691,6 +641,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U "quota_weekly_start", grokBillingExtraKey, UpstreamBillingProbeEnabledExtraKey, + UpstreamBillingRateSyncEnabledExtraKey, UpstreamBillingProbeExtraKey, OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, @@ -700,13 +651,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U normalizedExtra[key] = v } } - if hasRequestedProbeEnabled { - if isUpstreamBillingProbeAccount(account) { - normalizedExtra[UpstreamBillingProbeEnabledExtraKey] = requestedProbeEnabled - } else { - delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey) - } - } account.Extra = normalizedExtra if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 @@ -726,6 +670,35 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U ComputeQuotaResetAt(account.Extra) NormalizeFixedQuotaWindows(account.Extra) } + if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate { + if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate { + return nil, infraerrors.BadRequest( + "UPSTREAM_BILLING_RATE_SYNC_REQUIRES_PROBE", + "upstream billing rate sync requires upstream billing probe", + ) + } + enabled := true + requestedProbeEnabledUpdate = &enabled + } + if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate { + disabled := false + requestedRateSyncEnabledUpdate = &disabled + } + if (requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate) || + (requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate) { + if !isUpstreamBillingProbeAccount(account) { + return nil, ErrUpstreamBillingProbeAccountInvalid + } + } + if account.Extra == nil && (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) { + account.Extra = make(map[string]any) + } + if requestedProbeEnabledUpdate != nil { + account.Extra[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate + } + if requestedRateSyncEnabledUpdate != nil { + account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate + } // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 if input.ProxyID != nil && !account.IsCredentialShadow() { @@ -741,6 +714,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U delete(account.Extra, UpstreamBillingProbeExtraKey) if !isUpstreamBillingProbeAccount(account) { delete(account.Extra, UpstreamBillingProbeEnabledExtraKey) + delete(account.Extra, UpstreamBillingRateSyncEnabledExtraKey) } } if account.Extra != nil { @@ -766,6 +740,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if *input.RateMultiplier < 0 { return nil, errors.New("rate_multiplier must be >= 0") } + // 同步开启时倍率归上游所有,手工值活不过下一次成功探测(表现为"改了又自己 + // 变回去"),与批量路径一样直接拒绝。判断的是本次请求生效后的状态:上面 + // 已把请求携带的两个开关落进 account.Extra,所以"同一请求关闭同步 + 改倍率" + // (用户显式收回所有权)会走到这里时读到 false,正常放行。 + if upstreamBillingRateSyncEnabled(account) { + return nil, ErrUpstreamBillingRateSyncConflict + } account.RateMultiplier = input.RateMultiplier } if input.LoadFactor != nil { @@ -806,23 +787,40 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } } - probeEnabledAppliedAtomically := false - if requestedProbeEnabledUpdate != nil && isUpstreamBillingProbeAccount(account) { - if updater, ok := s.accountRepo.(accountProbeEnabledAtomicUpdater); ok { - if err := updater.UpdateWithUpstreamBillingProbeEnabled(ctx, account, *requestedProbeEnabledUpdate); err != nil { - return nil, err - } - probeEnabledAppliedAtomically = true - } + billingSettingsAppliedAtomically := false + updater := s.accountBillingRepo + if updater == nil { + // Unit tests and narrow internal callers may construct adminServiceImpl + // directly; production wiring requires this capability through + // AdminAccountRepository. + updater, _ = s.accountRepo.(AccountBillingSettingsRepository) } - if !probeEnabledAppliedAtomically { + if updater != nil { + if err := updater.UpdateWithAccountBillingSettings( + ctx, + account, + requestedProbeEnabledUpdate, + requestedRateSyncEnabledUpdate, + input.RateMultiplier, + ); err != nil { + return nil, err + } + billingSettingsAppliedAtomically = true + } + if !billingSettingsAppliedAtomically { if err := s.accountRepo.Update(ctx, account); err != nil { return nil, err } - if requestedProbeEnabledUpdate != nil && isUpstreamBillingProbeAccount(account) { - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - UpstreamBillingProbeEnabledExtraKey: *requestedProbeEnabledUpdate, - }); err != nil { + if (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) && + isUpstreamBillingProbeAccount(account) { + settings := make(map[string]any, 2) + if requestedProbeEnabledUpdate != nil { + settings[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate + } + if requestedRateSyncEnabledUpdate != nil { + settings[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, settings); err != nil { return nil, err } } @@ -854,6 +852,9 @@ 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 { + delete(updates, UpstreamBillingProbeEnabledExtraKey) + delete(updates, UpstreamBillingRateSyncEnabledExtraKey) + delete(updates, UpstreamBillingProbeExtraKey) delete(updates, OllamaCloudUsageSessionExtraKey) delete(updates, OllamaCloudUsageAutoRefreshExtraKey) delete(updates, OllamaCloudUsageSnapshotExtraKey) @@ -877,6 +878,7 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { // Managed probe/session state may only enter through dedicated typed endpoints. delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) + delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey) delete(input.Extra, UpstreamBillingProbeExtraKey) delete(input.Extra, OllamaCloudUsageSessionExtraKey) delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey) @@ -910,7 +912,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 var cachedTargets []*Account - if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil { + if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil || input.RateMultiplier != nil { loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) if err != nil { return nil, err @@ -996,12 +998,32 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if *input.RateMultiplier < 0 { return nil, errors.New("rate_multiplier must be >= 0") } + syncEnabledCount := 0 + for _, account := range cachedTargets { + if account == nil || account.Extra == nil { + continue + } + enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool) + if enabled { + syncEnabledCount++ + } + } + if syncEnabledCount > 0 { + return nil, ErrUpstreamBillingRateSyncBulkConflict.WithMetadata(map[string]string{ + "count": strconv.Itoa(syncEnabledCount), + }) + } } // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可) if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie + // only when platform is known Grok — empty platform still strips password/*). + if input.Credentials != nil { + input.Credentials = SanitizeStoredCredentials("", input.Credentials) + } // Prepare bulk updates for columns and JSONB fields. repoUpdates := AccountBulkUpdate{ @@ -1014,6 +1036,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp repoUpdates.Extra = make(map[string]any) } repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled + if !*input.ProbeEnabled { + repoUpdates.Extra[UpstreamBillingRateSyncEnabledExtraKey] = false + } } if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil { if repoUpdates.Extra == nil { diff --git a/backend/internal/service/admin_account_upstream_billing_probe_test.go b/backend/internal/service/admin_account_upstream_billing_probe_test.go index 88db653861..f76dde0b2e 100644 --- a/backend/internal/service/admin_account_upstream_billing_probe_test.go +++ b/backend/internal/service/admin_account_upstream_billing_probe_test.go @@ -17,6 +17,101 @@ func (r *upstreamBillingProbeAdminRepo) ListShadowsByParent(context.Context, int return nil, nil } +type accountBillingSettingsAdminRepo struct { + *upstreamBillingProbeAccountRepo + concurrentRate *float64 + lastExplicitRate *float64 + updateCalls int +} + +func (r *accountBillingSettingsAdminRepo) UpdateWithAccountBillingSettings( + _ context.Context, + account *Account, + probeEnabled *bool, + rateSyncEnabled *bool, + rateMultiplier *float64, +) error { + r.mu.Lock() + defer r.mu.Unlock() + + current := r.accounts[account.ID] + if current == nil { + return ErrAccountNotFound + } + updated := *account + updated.Credentials = mergeMap(nil, account.Credentials) + updated.Extra = mergeMap(nil, account.Extra) + if updated.Extra == nil { + updated.Extra = make(map[string]any) + } + if probeEnabled != nil { + updated.Extra[UpstreamBillingProbeEnabledExtraKey] = *probeEnabled + } + if rateSyncEnabled != nil { + updated.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *rateSyncEnabled + } + switch { + case rateMultiplier != nil: + value := *rateMultiplier + updated.RateMultiplier = &value + r.lastExplicitRate = &value + case r.concurrentRate != nil: + value := *r.concurrentRate + updated.RateMultiplier = &value + r.lastExplicitRate = nil + default: + updated.RateMultiplier = cloneAccountValuePointer(current.RateMultiplier) + r.lastExplicitRate = nil + } + r.accounts[account.ID] = &updated + r.updateCalls++ + return nil +} + +func TestUpdateAccountRoutesRateIntentThroughAtomicBillingUpdater(t *testing.T) { + accountID := int64(109) + initialRate := 0.1 + concurrentRate := 0.2 + repo := &accountBillingSettingsAdminRepo{ + upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Name: "before", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + RateMultiplier: &initialRate, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + }, + }, + }}, + concurrentRate: &concurrentRate, + } + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{Name: "after"}) + require.NoError(t, err) + require.Equal(t, 1, repo.updateCalls) + require.Nil(t, repo.lastExplicitRate) + require.Equal(t, concurrentRate, *updated.RateMultiplier) + + // 手工倍率只有在同步不再开启时才被接受,所以同一请求先关闭同步再设值 + // (同步仍开启时的手工倍率由 TestUpdateAccountRejectsManualRateWhileRateSyncEnabled 覆盖)。 + zero := 0.0 + syncDisabled := false + updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateSyncEnabled: &syncDisabled, + RateMultiplier: &zero, + }) + require.NoError(t, err) + require.Equal(t, 2, repo.updateCalls) + require.NotNil(t, repo.lastExplicitRate) + require.Zero(t, *repo.lastExplicitRate) + require.Zero(t, *updated.RateMultiplier) +} + func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) { repo := &upstreamBillingProbeAccountRepo{} svc := &adminServiceImpl{accountRepo: repo} @@ -28,13 +123,15 @@ func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) { Credentials: map[string]any{"api_key": "sk-test"}, SkipDefaultGroupBind: true, Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, }, }) require.NoError(t, err) require.NotContains(t, created.Extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, created.Extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotContains(t, created.Extra, UpstreamBillingProbeExtraKey) } @@ -73,8 +170,9 @@ func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit( Type: AccountTypeAPIKey, Status: StatusActive, Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, }, }, }} @@ -86,6 +184,7 @@ func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit( require.NoError(t, err) require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey]) require.Contains(t, updated.Extra, UpstreamBillingProbeExtraKey) require.Equal(t, "value", updated.Extra["custom"]) } @@ -196,8 +295,9 @@ func TestUpdateAccountInvalidatesProbeSnapshotWhenUpstreamIdentityChanges(t *tes "base_url": "https://old.example", }, Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, }, }, }} @@ -210,6 +310,7 @@ func TestUpdateAccountInvalidatesProbeSnapshotWhenUpstreamIdentityChanges(t *tes require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) } else { require.NotContains(t, updated.Extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey) } }) } @@ -289,16 +390,149 @@ func TestUpdateAccountAcceptsProbeEnabledAndRejectsInjectedSnapshot(t *testing.T svc := &adminServiceImpl{accountRepo: repo} updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, }, }) require.NoError(t, err) require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) + require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotContains(t, updated.Extra, UpstreamBillingProbeExtraKey) } +func TestUpdateAccountRateSyncControlsProbeAndManualMode(t *testing.T) { + accountID := int64(151) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Status: StatusActive, + Extra: map[string]any{}, + }, + }} + svc := &adminServiceImpl{accountRepo: repo} + + syncEnabled := true + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateSyncEnabled: &syncEnabled, + }) + require.NoError(t, err) + require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey]) + + syncEnabled = false + updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateSyncEnabled: &syncEnabled, + }) + require.NoError(t, err) + require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey]) +} + +// 单账号编辑必须和批量路径语义一致:同步开启时倍率归上游所有,手工值会在下一次 +// 成功探测时被覆盖,因此直接拒绝而不是静默接受。 +func TestUpdateAccountRejectsManualRateWhileRateSyncEnabled(t *testing.T) { + newRepo := func(accountID int64, extra map[string]any) *upstreamBillingProbeAccountRepo { + initialRate := 0.25 + return &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + RateMultiplier: &initialRate, + Extra: extra, + }, + }} + } + manualRate := 3.5 + syncEnabled := map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + } + + t.Run("sync enabled rejects manual rate", func(t *testing.T) { + accountID := int64(153) + repo := newRepo(accountID, mergeMap(nil, syncEnabled)) + + _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateMultiplier: &manualRate, + }) + + require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict) + require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier) + }) + + t.Run("enabling sync in the same request rejects manual rate", func(t *testing.T) { + accountID := int64(154) + repo := newRepo(accountID, map[string]any{}) + enable := true + + _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateSyncEnabled: &enable, + RateMultiplier: &manualRate, + }) + + require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict) + require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier) + }) + + // 用户显式收回所有权:同一请求关闭同步并改倍率必须放行。 + t.Run("disabling sync in the same request allows manual rate", func(t *testing.T) { + accountID := int64(155) + repo := newRepo(accountID, mergeMap(nil, syncEnabled)) + disable := false + + updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateSyncEnabled: &disable, + RateMultiplier: &manualRate, + }) + + require.NoError(t, err) + require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey]) + require.NotNil(t, updated.RateMultiplier) + require.Equal(t, manualRate, *updated.RateMultiplier) + }) + + t.Run("sync disabled allows manual rate", func(t *testing.T) { + accountID := int64(156) + repo := newRepo(accountID, map[string]any{UpstreamBillingProbeEnabledExtraKey: true}) + + updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + RateMultiplier: &manualRate, + }) + + require.NoError(t, err) + require.NotNil(t, updated.RateMultiplier) + require.Equal(t, manualRate, *updated.RateMultiplier) + }) +} + +func TestUpdateAccountRejectsSyncWithExplicitlyDisabledProbe(t *testing.T) { + accountID := int64(152) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: { + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + }, + }} + probeEnabled := false + syncEnabled := true + + _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + ProbeEnabled: &probeEnabled, + RateSyncEnabled: &syncEnabled, + }) + + require.Error(t, err) + require.Empty(t, repo.updates[accountID]) +} + func TestUpdateAccountExplicitProbeDisableUsesDedicatedExtraUpdate(t *testing.T) { accountID := int64(113) repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ @@ -321,6 +555,7 @@ func TestUpdateAccountExplicitProbeDisableUsesDedicatedExtraUpdate(t *testing.T) require.NoError(t, err) require.Len(t, repo.updates[accountID], 1) require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingRateSyncEnabledExtraKey]) } func TestUpdateAccountExplicitUnchangedProbeEnabledStillUsesDedicatedExtraUpdate(t *testing.T) { @@ -364,15 +599,36 @@ func TestUpdateAccountRejectsInvalidProbeEnabled(t *testing.T) { require.Error(t, err) } +func TestUpdateAccountExtraDropsManagedBillingProbeFields(t *testing.T) { + accountID := int64(153) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: {ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + }} + + err := (&adminServiceImpl{accountRepo: repo}).UpdateAccountExtra(context.Background(), accountID, map[string]any{ + "custom": "value", + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + }) + + require.NoError(t, err) + require.Equal(t, "value", repo.accounts[accountID].Extra["custom"]) + require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingRateSyncEnabledExtraKey) + require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeExtraKey) +} + func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) { repo := &upstreamBillingProbeAccountRepo{} svc := &adminServiceImpl{accountRepo: repo} input := &BulkUpdateAccountsInput{ AccountIDs: []int64{1}, Extra: map[string]any{ - "custom": "value", - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + "custom": "value", + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, }, } @@ -383,6 +639,7 @@ func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) { require.Len(t, repo.bulkUpdates, 1) require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"]) require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey) } @@ -403,6 +660,9 @@ func TestBulkUpdateAccountsAcceptsDedicatedUpstreamBillingProbeSetting(t *testin require.Equal(t, 2, result.Success) require.Len(t, repo.bulkUpdates, 1) require.Equal(t, enabled, repo.bulkUpdates[0].Extra[UpstreamBillingProbeEnabledExtraKey]) + if !enabled { + require.Equal(t, false, repo.bulkUpdates[0].Extra[UpstreamBillingRateSyncEnabledExtraKey]) + } require.NotNil(t, repo.bulkUpdates[0].ProbeEnabled) require.Equal(t, enabled, *repo.bulkUpdates[0].ProbeEnabled) }) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 68822bd3d7..5744f40cfe 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -300,10 +300,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn return nil, errors.New("rate_multiplier must be > 0") } - platform := input.Platform - if platform == "" { - platform = PlatformAnthropic - } + platform := NormalizeGroupPlatform(input.Platform) maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(platform, input.MaxReasoningEffort) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) @@ -331,6 +328,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn videoPrice720P := normalizePrice(input.VideoPrice720P) videoPrice1080P := normalizePrice(input.VideoPrice1080P) webSearchPricePerCall := normalizePrice(input.WebSearchPricePerCall) + searchPricePer1k := normalizePrice(input.SearchPricePer1k) + audioRealtimePricePerMin := normalizePrice(input.AudioRealtimePricePerMin) + audioTTSPricePerMillionChars := normalizePrice(input.AudioTTSPricePerMillionChars) + audioSTTPricePerHour := normalizePrice(input.AudioSTTPricePerHour) imageRateMultiplier := 1.0 if input.ImageRateMultiplier != nil { if *input.ImageRateMultiplier < 0 { @@ -375,6 +376,20 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn return nil, err } + profitMinMargin := 0.0 + if input.ProfitMinMargin != nil { + profitMinMargin = *input.ProfitMinMargin + } + profitSafetyBuffer := 0.0 + if input.ProfitSafetyBuffer != nil { + profitSafetyBuffer = *input.ProfitSafetyBuffer + } + // 利润控制与高峰倍率同一收口顺序:先按平台归一化(不支持的平台重置),再校验。 + profitControlEnabled, profitMinMargin, profitSafetyBuffer := NormalizeProfitControlConfig(platform, input.ProfitControlEnabled, profitMinMargin, profitSafetyBuffer) + if err := ValidateProfitControlConfig(platform, profitControlEnabled, profitMinMargin, profitSafetyBuffer); err != nil { + return nil, err + } + // 校验降级分组 if input.FallbackGroupID != nil { if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil { @@ -456,13 +471,21 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn PeakStart: peakStart, PeakEnd: peakEnd, PeakRateMultiplier: peakRateMultiplier, + ProfitControlEnabled: profitControlEnabled, + ProfitMinMargin: profitMinMargin, + ProfitSafetyBuffer: profitSafetyBuffer, ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, ImagePrice4K: imagePrice4K, VideoPrice480P: videoPrice480P, VideoPrice720P: videoPrice720P, VideoPrice1080P: videoPrice1080P, + VideoModelPrices: NormalizeVideoModelPrices(input.VideoModelPrices), WebSearchPricePerCall: webSearchPricePerCall, + SearchPricePer1k: searchPricePer1k, + AudioRealtimePricePerMin: audioRealtimePricePerMin, + AudioTTSPricePerMillionChars: audioTTSPricePerMillionChars, + AudioSTTPricePerHour: audioSTTPricePerHour, ClaudeCodeOnly: input.ClaudeCodeOnly, FallbackGroupID: input.FallbackGroupID, FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, @@ -708,6 +731,21 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { return nil, err } + if input.ProfitControlEnabled != nil { + group.ProfitControlEnabled = *input.ProfitControlEnabled + } + if input.ProfitMinMargin != nil { + group.ProfitMinMargin = *input.ProfitMinMargin + } + if input.ProfitSafetyBuffer != nil { + group.ProfitSafetyBuffer = *input.ProfitSafetyBuffer + } + // 利润控制与高峰同一收口:按合并后的最终平台归一化(转到不支持平台时静默重置), + // 再对合并后的最终配置统一校验,防止部分字段更新拼出非法组合入库。 + group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer = NormalizeProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer) + if err := ValidateProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer); err != nil { + return nil, err + } if input.ImagePrice1K != nil { group.ImagePrice1K = normalizePrice(input.ImagePrice1K) } @@ -726,9 +764,25 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.VideoPrice1080P != nil { group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P) } + // nil = leave unchanged; empty map = clear per-model prices. + if input.VideoModelPrices != nil { + group.VideoModelPrices = NormalizeVideoModelPrices(input.VideoModelPrices) + } if input.WebSearchPricePerCall != nil { group.WebSearchPricePerCall = normalizePrice(input.WebSearchPricePerCall) } + if input.SearchPricePer1k != nil { + group.SearchPricePer1k = normalizePrice(input.SearchPricePer1k) + } + if input.AudioRealtimePricePerMin != nil { + group.AudioRealtimePricePerMin = normalizePrice(input.AudioRealtimePricePerMin) + } + if input.AudioTTSPricePerMillionChars != nil { + group.AudioTTSPricePerMillionChars = normalizePrice(input.AudioTTSPricePerMillionChars) + } + if input.AudioSTTPricePerHour != nil { + group.AudioSTTPricePerHour = normalizePrice(input.AudioSTTPricePerHour) + } // Claude Code 客户端限制 if input.ClaudeCodeOnly != nil { diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index fcfe39326d..deda6d3822 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -67,6 +67,21 @@ func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 { return cloned } +func cloneGroupVideoModelPrices(value map[string]map[string]float64) map[string]map[string]float64 { + if value == nil { + return nil + } + cloned := make(map[string]map[string]float64, len(value)) + for model, prices := range value { + clonedPrices := make(map[string]float64, len(prices)) + for resolution, price := range prices { + clonedPrices[resolution] = price + } + cloned[model] = clonedPrices + } + return cloned +} + func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig { cloned := value if value.ExactModelMappings != nil { @@ -88,6 +103,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { PeakStart: source.PeakStart, PeakEnd: source.PeakEnd, PeakRateMultiplier: source.PeakRateMultiplier, + ProfitControlEnabled: source.ProfitControlEnabled, + ProfitMinMargin: source.ProfitMinMargin, + ProfitSafetyBuffer: source.ProfitSafetyBuffer, IsExclusive: source.IsExclusive, Status: duplicateGroupInactiveStatus, DuplicateOperationID: operationID, @@ -110,7 +128,12 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P), VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P), VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P), + VideoModelPrices: cloneGroupVideoModelPrices(source.VideoModelPrices), WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall), + SearchPricePer1k: cloneGroupValuePointer(source.SearchPricePer1k), + AudioRealtimePricePerMin: cloneGroupValuePointer(source.AudioRealtimePricePerMin), + AudioTTSPricePerMillionChars: cloneGroupValuePointer(source.AudioTTSPricePerMillionChars), + AudioSTTPricePerHour: cloneGroupValuePointer(source.AudioSTTPricePerHour), ClaudeCodeOnly: source.ClaudeCodeOnly, FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID), FallbackGroupIDOnInvalidRequest: cloneGroupValuePointer(source.FallbackGroupIDOnInvalidRequest), diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 44db1afdc7..f4a7bef2c5 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -121,37 +121,40 @@ func groupDuplicateTestPointer[T any](value T) *T { return &value } func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) { createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC) source := &Group{ - ID: 41, - Name: "高级订阅", - Description: "configuration", - Platform: PlatformOpenAI, - RateMultiplier: 1.75, - PeakRateEnabled: true, - PeakStart: "09:00", - PeakEnd: "18:00", - PeakRateMultiplier: 1.2, - IsExclusive: true, - Status: StatusActive, - Hydrated: true, - SubscriptionType: SubscriptionTypeSubscription, - DailyLimitUSD: groupDuplicateTestPointer(11.0), - WeeklyLimitUSD: groupDuplicateTestPointer(22.0), - MonthlyLimitUSD: groupDuplicateTestPointer(33.0), - DefaultValidityDays: 91, - AllowImageGeneration: true, - AllowBatchImageGeneration: true, - ImageRateIndependent: true, - ImageRateMultiplier: 1.4, - ImagePrice1K: groupDuplicateTestPointer(0.01), - ImagePrice2K: groupDuplicateTestPointer(0.02), - ImagePrice4K: groupDuplicateTestPointer(0.04), - BatchImageDiscountMultiplier: 0.4, - BatchImageHoldMultiplier: 0.7, - VideoRateIndependent: true, - VideoRateMultiplier: 2.1, - VideoPrice480P: groupDuplicateTestPointer(0.1), - VideoPrice720P: groupDuplicateTestPointer(0.2), - VideoPrice1080P: groupDuplicateTestPointer(0.3), + ID: 41, + Name: "高级订阅", + Description: "configuration", + Platform: PlatformOpenAI, + RateMultiplier: 1.75, + PeakRateEnabled: true, + PeakStart: "09:00", + PeakEnd: "18:00", + PeakRateMultiplier: 1.2, + IsExclusive: true, + Status: StatusActive, + Hydrated: true, + SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: groupDuplicateTestPointer(11.0), + WeeklyLimitUSD: groupDuplicateTestPointer(22.0), + MonthlyLimitUSD: groupDuplicateTestPointer(33.0), + DefaultValidityDays: 91, + AllowImageGeneration: true, + AllowBatchImageGeneration: true, + ImageRateIndependent: true, + ImageRateMultiplier: 1.4, + ImagePrice1K: groupDuplicateTestPointer(0.01), + ImagePrice2K: groupDuplicateTestPointer(0.02), + ImagePrice4K: groupDuplicateTestPointer(0.04), + BatchImageDiscountMultiplier: 0.4, + BatchImageHoldMultiplier: 0.7, + VideoRateIndependent: true, + VideoRateMultiplier: 2.1, + VideoPrice480P: groupDuplicateTestPointer(0.1), + VideoPrice720P: groupDuplicateTestPointer(0.2), + VideoPrice1080P: groupDuplicateTestPointer(0.3), + VideoModelPrices: map[string]map[string]float64{ + VideoPriceFamilyGrokImagineVideo15: {VideoBillingResolution720P: 0.14}, + }, WebSearchPricePerCall: groupDuplicateTestPointer(0.005), ClaudeCodeOnly: true, FallbackGroupID: groupDuplicateTestPointer(int64(7)), @@ -204,6 +207,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier) require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays) require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K) + require.Equal(t, source.VideoModelPrices, duplicate.VideoModelPrices) require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall) require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID) require.Equal(t, source.ModelRouting, duplicate.ModelRouting) @@ -222,12 +226,14 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing }, repo.createdBindings[duplicate.ID]) duplicate.ModelRouting["gpt-*"][0] = 999 + duplicate.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P] = 999 duplicate.SupportedModelScopes[0] = "changed" duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed" duplicate.ModelsListConfig.Models[0] = "changed" duplicate.ReasoningEffortMappings[0].To = "changed" *duplicate.DailyLimitUSD = 999 require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0]) + require.Equal(t, 0.14, source.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P]) require.Equal(t, "claude", source.SupportedModelScopes[0]) require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"]) require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0]) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index bd907125a5..d9c4060c9e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -238,10 +238,18 @@ type CreateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价。 + VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);nil/负数按默认价 0.01 处理 WebSearchPricePerCall *float64 - ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // 搜索工具单价 per 1k + SearchPricePer1k *float64 + // Grok Voice 显式定价(分组级) + AudioRealtimePricePerMin *float64 + AudioTTSPricePerMillionChars *float64 + AudioSTTPricePerHour *float64 + ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -264,6 +272,10 @@ type CreateGroupInput struct { MaxReasoningEffort string // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 ReasoningEffortMappings []ReasoningEffortMapping + // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) + ProfitControlEnabled bool + ProfitMinMargin *float64 + ProfitSafetyBuffer *float64 // 从指定分组复制账号(创建分组后在同一事务内绑定) CopyAccountsFromGroupIDs []int64 } @@ -299,10 +311,18 @@ type UpdateGroupInput struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // VideoModelPrices 可选按模型族×分辨率覆盖;nil 表示不修改,空 map 表示清除。 + VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次);nil 表示不修改,负数表示清除回默认价 0.01 WebSearchPricePerCall *float64 - ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + // 搜索工具单价;nil 不修改,负数清除 + SearchPricePer1k *float64 + // Grok Voice 显式定价;nil 表示不修改,负数表示清除 + AudioRealtimePricePerMin *float64 + AudioTTSPricePerMillionChars *float64 + AudioSTTPricePerHour *float64 + ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -325,6 +345,10 @@ type UpdateGroupInput struct { MaxReasoningEffort *string // ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。 ReasoningEffortMappings *[]ReasoningEffortMapping + // 分组利润控制(nil 表示不修改;margin/buffer 为小数) + ProfitControlEnabled *bool + ProfitMinMargin *float64 + ProfitSafetyBuffer *float64 // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 } @@ -376,6 +400,8 @@ type UpdateAccountInput struct { GroupIDs *[]int64 ExpiresAt *int64 AutoPauseOnExpired *bool + ProbeEnabled *bool + RateSyncEnabled *bool SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险) } @@ -617,6 +643,7 @@ type adminServiceImpl struct { groupDuplicateRepo GroupDuplicateRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository + accountBillingRepo AccountBillingSettingsRepository proxyRepo ProxyRepository apiKeyRepo APIKeyRepository redeemCodeRepo RedeemCodeRepository @@ -675,6 +702,7 @@ func NewAdminService( groupDuplicateRepo: groupRepo, accountRepo: accountRepo, accountDuplicateRepo: accountRepo, + accountBillingRepo: accountRepo, proxyRepo: proxyRepo, apiKeyRepo: apiKeyRepo, redeemCodeRepo: redeemCodeRepo, diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go index 63bf26fbd1..206efb21f1 100644 --- a/backend/internal/service/admin_service_bulk_update_test.go +++ b/backend/internal/service/admin_service_bulk_update_test.go @@ -5,9 +5,11 @@ package service import ( "context" "errors" + "net/http" "reflect" "testing" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" ) @@ -153,6 +155,38 @@ func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) { require.Len(t, result.Results, 3) } +func TestAdminService_BulkUpdateAccounts_RejectsRateChangeForSyncedAccounts(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{ + getByIDsAccounts: []*Account{ + { + ID: 1, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + }, + }, + {ID: 2, Extra: map[string]any{}}, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + rateMultiplier := 0.5 + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1, 2}, + RateMultiplier: &rateMultiplier, + }) + + require.Nil(t, result) + require.Error(t, err) + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.Equal(t, int32(http.StatusConflict), appErr.Code) + require.Equal(t, "UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT", appErr.Reason) + require.Equal(t, "1", appErr.Metadata["count"]) + require.True(t, repo.getByIDsCalled) + require.Empty(t, repo.bulkUpdateIDs, "rate conflict must be rejected before any write") +} + // TestAdminService_BulkUpdateAccounts_PartialFailureIDs 验证部分失败时 success_ids/failed_ids 正确。 func TestAdminService_BulkUpdateAccounts_PartialFailureIDs(t *testing.T) { repo := &accountRepoStubForBulkUpdate{ diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 73095abc5f..5530720f5e 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -41,14 +41,20 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te svc := &adminServiceImpl{groupRepo: groupRepo} group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ - Name: "Composite", - Platform: PlatformComposite, - RateMultiplier: 1, + Name: "Composite", + Platform: PlatformComposite, + RateMultiplier: 1, + MaxReasoningEffort: "medium", + ReasoningEffortMappings: []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, CopyAccountsFromGroupIDs: []int64{10, 20, 10}, }) require.NoError(t, err) require.Equal(t, PlatformComposite, groupRepo.created.Platform) + require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort) + require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings) require.Equal(t, int64(99), group.ID) require.Equal(t, int64(2), group.AccountCount) require.ElementsMatch(t, []int64{10, 20}, copiedFrom) @@ -82,13 +88,19 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te }, } svc := &adminServiceImpl{groupRepo: groupRepo} + maxReasoningEffort := "low" + reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}} group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{ + MaxReasoningEffort: &maxReasoningEffort, + ReasoningEffortMappings: &reasoningEffortMappings, CopyAccountsFromGroupIDs: []int64{10, 20}, }) require.NoError(t, err) require.Equal(t, PlatformComposite, group.Platform) + require.Equal(t, "low", group.MaxReasoningEffort) + require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings) require.Equal(t, int64(99), clearedGroupID) require.ElementsMatch(t, []int64{10, 20}, copiedFrom) require.Equal(t, int64(99), boundGroupID) diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 3ce97f3343..5925a59dda 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -13,21 +13,45 @@ import ( ) type userRepoStub struct { - user *User - getErr error - createErr error - deleteErr error - exists bool - existsErr error - aliasExists bool - aliasErr error - guardedCreates int - nextID int64 - created []*User - updated []*User - deletedIDs []int64 - usersByEmail map[string]*User - getByEmailErr error + user *User + usersByID map[int64]*User + getErr error + createErr error + deleteErr error + exists bool + existsErr error + aliasExists bool + aliasErr error + guardedCreates int + nextID int64 + created []*User + updated []*User + deletedIDs []int64 + usersByEmail map[string]*User + getByEmailErr error + getByEmailMisses int + domainCounts map[string]int + domainCountErr error + domainLimitErr error + domainLimitedCreates int +} + +func (s *userRepoStub) CountUsersByEmailDomain(_ context.Context, domain string) (int, error) { + if s.domainCountErr != nil { + return 0, s.domainCountErr + } + return s.domainCounts[domain], nil +} + +func (s *userRepoStub) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *User, domain string) error { + s.domainLimitedCreates++ + if s.domainLimitErr != nil { + return s.domainLimitErr + } + if s.domainCounts[domain] > 0 { + return ErrEmailDomainRegistrationLimit + } + return s.CreateWithEmailAliasGuard(ctx, user) } func (s *userRepoStub) Create(ctx context.Context, user *User) error { @@ -61,6 +85,12 @@ func (s *userRepoStub) GetByID(ctx context.Context, id int64) (*User, error) { if s.getErr != nil { return nil, s.getErr } + if s.usersByID != nil { + if user, ok := s.usersByID[id]; ok { + return user, nil + } + return nil, ErrUserNotFound + } if s.user == nil { return nil, ErrUserNotFound } @@ -71,6 +101,10 @@ func (s *userRepoStub) GetByEmail(ctx context.Context, email string) (*User, err if s.getByEmailErr != nil { return nil, s.getByEmailErr } + if s.getByEmailMisses > 0 { + s.getByEmailMisses-- + return nil, ErrUserNotFound + } if s.usersByEmail != nil { if user, ok := s.usersByEmail[email]; ok { return user, nil diff --git a/backend/internal/service/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go index 7b19dbf6f5..7bd31778d7 100644 --- a/backend/internal/service/admin_service_duplicate_account_test.go +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -141,6 +141,7 @@ func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) SessionWindowStatus: "active", } source.Extra[UpstreamBillingProbeEnabledExtraKey] = true + source.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true source.Extra[UpstreamBillingProbeExtraKey] = map[string]any{"status": "ok"} require.NoError(t, repo.Create(ctx, source)) @@ -162,6 +163,7 @@ func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) "quota_limit": float64(1000), "codex_cli_only": true, }, duplicate.Extra) + require.NotContains(t, duplicate.Extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotNil(t, duplicate.ExpiresAt) require.True(t, source.ExpiresAt.Equal(*duplicate.ExpiresAt)) require.Equal(t, source.Notes, duplicate.Notes) diff --git a/backend/internal/service/aliyun_captcha_service.go b/backend/internal/service/aliyun_captcha_service.go new file mode 100644 index 0000000000..0a66320a22 --- /dev/null +++ b/backend/internal/service/aliyun_captcha_service.go @@ -0,0 +1,171 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +var ( + ErrAliyunCaptchaVerificationFailed = infraerrors.BadRequest("ALIYUN_CAPTCHA_VERIFICATION_FAILED", "aliyun captcha verification failed") + ErrAliyunCaptchaNotConfigured = infraerrors.ServiceUnavailable("ALIYUN_CAPTCHA_NOT_CONFIGURED", "aliyun captcha not configured") + // ErrCaptchaInvalidCredentials 阿里云验证码凭证无效(仅后台保存校验时返回,公开接口错误码不变) + ErrCaptchaInvalidCredentials = infraerrors.BadRequest("CAPTCHA_INVALID_CREDENTIALS", "invalid aliyun captcha credentials") +) + +// AliyunCaptchaCredentials 阿里云验证码 2.0 服务端校验所需的完整凭证 +type AliyunCaptchaCredentials struct { + AccessKeyID string + AccessKeySecret string + SceneID string + Endpoint string +} + +// AliyunCaptchaVerifyResult VerifyIntelligentCaptcha 的归一化结果 +type AliyunCaptchaVerifyResult struct { + VerifyResult bool + VerifyCode string // 阿里云细分结果码,仅用于日志 +} + +// AliyunCaptchaAPIError 阿里云 OpenAPI 业务错误。 +// repository 层负责把 SDK 错误归一化为该类型,service 层不依赖 SDK 包。 +type AliyunCaptchaAPIError struct { + Code string + Message string +} + +func (e *AliyunCaptchaAPIError) Error() string { + return fmt.Sprintf("aliyun captcha api error: %s: %s", e.Code, e.Message) +} + +// AliyunCaptchaVerifier 调用阿里云验证码 2.0 服务端校验的端口 +type AliyunCaptchaVerifier interface { + VerifyCaptcha(ctx context.Context, cred AliyunCaptchaCredentials, captchaVerifyParam string) (*AliyunCaptchaVerifyResult, error) +} + +const ( + // AliyunCaptchaRegionCN 中国内地;AliyunCaptchaRegionSGP 新加坡。 + // 该值同时下发给前端 AliyunCaptchaConfig.region,两端必须一致。 + AliyunCaptchaRegionCN = "cn" + AliyunCaptchaRegionSGP = "sgp" + + aliyunCaptchaEndpointCN = "captcha.cn-shanghai.aliyuncs.com" + aliyunCaptchaEndpointSGP = "captcha.ap-southeast-1.aliyuncs.com" +) + +// aliyunCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国内地 +func aliyunCaptchaEndpoint(region string) string { + if region == AliyunCaptchaRegionSGP { + return aliyunCaptchaEndpointSGP + } + return aliyunCaptchaEndpointCN +} + +// normalizeAliyunCaptchaRegion 非法值一律视为中国内地 +func normalizeAliyunCaptchaRegion(value string) string { + if value == AliyunCaptchaRegionSGP { + return AliyunCaptchaRegionSGP + } + return AliyunCaptchaRegionCN +} + +// aliyunCredentialValidationParam 用于后台保存时探测凭证有效性的假验证参数 +const aliyunCredentialValidationParam = "sub2api-credential-validation" + +// aliyunInvalidCredentialCodes 表示 AK/SK 本身无效的阿里云错误码; +// 其余错误码(如 param 无效)说明签名已通过、凭证可用。 +var aliyunInvalidCredentialCodes = map[string]struct{}{ + "InvalidAccessKeyId.NotFound": {}, + "InvalidAccessKeyId.Inactive": {}, + "SignatureDoesNotMatch": {}, + "Forbidden.AccessKeyDisabled": {}, + "IncompleteSignature": {}, + "InvalidSecurityToken.Expired": {}, +} + +// AliyunCaptchaService 阿里云验证码 2.0 服务端校验 +type AliyunCaptchaService struct { + settingService *SettingService + verifier AliyunCaptchaVerifier +} + +func NewAliyunCaptchaService(settingService *SettingService, verifier AliyunCaptchaVerifier) *AliyunCaptchaService { + return &AliyunCaptchaService{settingService: settingService, verifier: verifier} +} + +func aliyunCaptchaCredentials(config AliyunCaptchaConfig) (AliyunCaptchaCredentials, bool) { + cred := AliyunCaptchaCredentials{ + AccessKeyID: strings.TrimSpace(config.AccessKeyID), + AccessKeySecret: strings.TrimSpace(config.AccessKeySecret), + SceneID: strings.TrimSpace(config.SceneID), + Endpoint: aliyunCaptchaEndpoint(config.Region), + } + if cred.AccessKeyID == "" || cred.AccessKeySecret == "" || cred.SceneID == "" { + return AliyunCaptchaCredentials{}, false + } + return cred, true +} + +// VerifyParamWithConfig 校验阿里云验证码 2.0 的 captchaVerifyParam。 +// 调用异常时返回错误(fail-closed),与 Turnstile 网络错误行为对称。 +func (s *AliyunCaptchaService) VerifyParamWithConfig(ctx context.Context, config AliyunCaptchaConfig, captchaVerifyParam string) error { + if s == nil || s.verifier == nil { + return ErrAliyunCaptchaNotConfigured + } + cred, ok := aliyunCaptchaCredentials(config) + if !ok { + logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] credentials not configured") + return ErrAliyunCaptchaNotConfigured + } + + if strings.TrimSpace(captchaVerifyParam) == "" { + logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] captchaVerifyParam is empty") + return ErrAliyunCaptchaVerificationFailed + } + + result, err := s.verifier.VerifyCaptcha(ctx, cred, captchaVerifyParam) + if err != nil { + logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] verify request failed: %v", err) + return fmt.Errorf("%w: verifier request failed", ErrAliyunCaptchaVerificationFailed) + } + + if result == nil || !result.VerifyResult { + if result != nil { + logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] rejected, verify code: %s", result.VerifyCode) + } + return ErrAliyunCaptchaVerificationFailed + } + return nil +} + +// ValidateCredentials 用假验证参数探测阿里云 AK/SK 是否可用(后台保存设置时调用)。 +// 凭证类错误码返回 ErrCaptchaInvalidCredentials;正常响应(包括 param 无效导致的 +// VerifyResult=false)说明签名通过、凭证有效;其余错误原样返回给管理员排查。 +func (s *AliyunCaptchaService) ValidateCredentials(ctx context.Context, accessKeyID, accessKeySecret, sceneID, region string) error { + if s.verifier == nil { + return ErrAliyunCaptchaNotConfigured + } + cred := AliyunCaptchaCredentials{ + AccessKeyID: accessKeyID, + AccessKeySecret: accessKeySecret, + SceneID: sceneID, + Endpoint: aliyunCaptchaEndpoint(region), + } + + _, err := s.verifier.VerifyCaptcha(ctx, cred, aliyunCredentialValidationParam) + if err != nil { + var apiErr *AliyunCaptchaAPIError + if errors.As(err, &apiErr) { + if _, invalid := aliyunInvalidCredentialCodes[apiErr.Code]; invalid { + return ErrCaptchaInvalidCredentials + } + } + return fmt.Errorf("validate aliyun captcha credentials: %w", err) + } + + return nil +} diff --git a/backend/internal/service/aliyun_captcha_service_test.go b/backend/internal/service/aliyun_captcha_service_test.go new file mode 100644 index 0000000000..68b1e9e535 --- /dev/null +++ b/backend/internal/service/aliyun_captcha_service_test.go @@ -0,0 +1,233 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type aliyunVerifierSpy struct { + called int + lastCred AliyunCaptchaCredentials + lastParam string + result *AliyunCaptchaVerifyResult + err error +} + +func (s *aliyunVerifierSpy) VerifyCaptcha(_ context.Context, cred AliyunCaptchaCredentials, param string) (*AliyunCaptchaVerifyResult, error) { + s.called++ + s.lastCred = cred + s.lastParam = param + if s.err != nil { + return nil, s.err + } + if s.result != nil { + return s.result, nil + } + return &AliyunCaptchaVerifyResult{VerifyResult: true}, nil +} + +func aliyunEnabledSettings() map[string]string { + return map[string]string{ + SettingKeyAliyunCaptchaEnabled: "true", + SettingKeyAliyunCaptchaAccessKeyID: "ak-id", + SettingKeyAliyunCaptchaAccessKeySecret: "ak-secret", + SettingKeyAliyunCaptchaSceneID: "scene-1", + SettingKeyAliyunCaptchaPrefix: "prefix-1", + } +} + +func aliyunTestConfig() AliyunCaptchaConfig { + return AliyunCaptchaConfig{ + Enabled: true, + AccessKeyID: "ak-id", + AccessKeySecret: "ak-secret", + SceneID: "scene-1", + Region: AliyunCaptchaRegionCN, + } +} + +func newAliyunAuthServiceForTest(cfg *config.Config, settings map[string]string, aliyunSpy *aliyunVerifierSpy) *AuthService { + settingService := NewSettingService(&settingPublicRepoStub{values: settings}, cfg) + authService := NewAuthService( + nil, // entClient + nil, // userRepo + nil, // redeemRepo + nil, // refreshTokenCache + cfg, + settingService, + nil, // emailService + NewTurnstileService(settingService, &turnstileVerifierSpy{}), + nil, // emailQueueService + nil, // promoService + nil, // defaultSubAssigner + nil, // affiliateService + nil, // userPlatformQuotaRepo + ) + authService.SetAliyunCaptchaService(NewAliyunCaptchaService(settingService, aliyunSpy)) + return authService +} + +func TestAliyunCaptchaServiceVerifyParamDispatch(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) + require.Equal(t, "ak-id", spy.lastCred.AccessKeyID) + require.Equal(t, "scene-1", spy.lastCred.SceneID) + require.Equal(t, "captcha.cn-shanghai.aliyuncs.com", spy.lastCred.Endpoint) +} + +func TestAliyunCaptchaServiceSgpEndpoint(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + cfg := aliyunTestConfig() + cfg.Region = AliyunCaptchaRegionSGP + + err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param") + + require.NoError(t, err) + require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint) +} + +func TestAliyunCaptchaServiceFailsClosedOnVerifierError(t *testing.T) { + spy := &aliyunVerifierSpy{err: errors.New("network down")} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) +} + +func TestAliyunCaptchaServiceRejectsVerifyResultFalse(t *testing.T) { + spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false, VerifyCode: "F001"}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) +} + +func TestAliyunCaptchaServiceRejectsIncompleteCredentials(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + cfg := aliyunTestConfig() + cfg.AccessKeySecret = "" + + err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param") + + require.ErrorIs(t, err, ErrAliyunCaptchaNotConfigured) + require.Zero(t, spy.called) +} + +func TestAliyunCaptchaServiceRejectsEmptyParam(t *testing.T) { + spy := &aliyunVerifierSpy{} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "") + + require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed) + require.Zero(t, spy.called) +} + +func TestAliyunCaptchaServiceValidateCredentials(t *testing.T) { + t.Run("invalid credential code", func(t *testing.T) { + spy := &aliyunVerifierSpy{err: &AliyunCaptchaAPIError{Code: "SignatureDoesNotMatch", Message: "bad sk"}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn") + require.ErrorIs(t, err, ErrCaptchaInvalidCredentials) + }) + + t.Run("network error surfaces", func(t *testing.T) { + spy := &aliyunVerifierSpy{err: errors.New("timeout")} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn") + require.Error(t, err) + require.NotErrorIs(t, err, ErrCaptchaInvalidCredentials) + }) + + t.Run("verify result false means credentials valid", func(t *testing.T) { + spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false}} + svc := NewAliyunCaptchaService(nil, spy) + + err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "sgp") + require.NoError(t, err) + require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint) + }) +} + +func TestAuthServiceVerifyCaptchaDispatchesAliyun(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy) + + // 阿里云 captchaVerifyParam 复用 turnstile_token 请求字段 + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) +} + +func TestAuthServiceVerifyCaptchaRejectsProviderConflict(t *testing.T) { + settings := aliyunEnabledSettings() + settings[SettingKeyTurnstileEnabled] = "true" + settings[SettingKeyTurnstileSecretKey] = "secret" + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, settings, spy) + + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "param"}, "127.0.0.1") + + require.ErrorIs(t, err, ErrCaptchaProviderConflict) + require.Zero(t, spy.called) +} + +func TestAuthServiceVerifyCaptchaRequiredModeWithAliyun(t *testing.T) { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: true}, + } + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(cfg, aliyunEnabledSettings(), spy) + + // required 模式 + 阿里云启用且凭证齐全:不误报 NOT_CONFIGURED,正常走阿里云校验 + err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) +} + +func TestAuthServiceVerifyActionCaptchaIfEnabledDispatchesAliyun(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy) + + err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1") + + require.NoError(t, err) + require.Equal(t, 1, spy.called) + require.Equal(t, "captcha-verify-param", spy.lastParam) +} + +func TestAuthServiceVerifyActionCaptchaIfEnabledSkipsWhenOnlyTurnstile(t *testing.T) { + spy := &aliyunVerifierSpy{} + authService := newAliyunAuthServiceForTest(&config.Config{}, map[string]string{ + SettingKeyTurnstileEnabled: "true", + SettingKeyTurnstileSecretKey: "secret", + }, spy) + + // Turnstile 不扩大既有覆盖:扩展入口不拦截 + err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "127.0.0.1") + + require.NoError(t, err) + require.Zero(t, spy.called) +} diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 2011625984..3511f60288 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -28,6 +28,7 @@ import ( // ├─ 成功 → 正常返回 // └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) // 上游透传账号直接转发,不走 OAuth token 刷新 if account.Type == AccountTypeUpstream { return s.ForwardUpstream(ctx, c, account, body) @@ -448,14 +449,16 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: claudeReq.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: billingModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: claudeReq.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index af1b8ebbe4..ab8f0d17a2 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -168,6 +168,7 @@ func (s *AntigravityGatewayService) forwardAntigravityCompat( account *Account, request antigravityCompatRequest, ) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) call, err := s.prepareAntigravityCompatCall(ctx, c, account, request) if err != nil { return nil, err @@ -324,15 +325,17 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse( } return &ForwardResult{ - RequestID: requestID, - Usage: *streamResult.usage, - Model: call.request.originalModel, - UpstreamModel: call.billingModel, - Stream: call.request.clientStream, - Duration: time.Since(call.request.startTime), - FirstTokenMs: streamResult.firstTokenMs, - ReasoningEffort: call.request.reasoningEffort, - ClientDisconnect: streamResult.clientDisconnect, + RequestID: requestID, + Usage: *streamResult.usage, + Model: call.request.originalModel, + UpstreamModel: call.billingModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: call.request.clientStream, + Duration: time.Since(call.request.startTime), + FirstTokenMs: streamResult.firstTokenMs, + ReasoningEffort: call.request.reasoningEffort, + ClientDisconnect: streamResult.clientDisconnect, }, nil } @@ -477,7 +480,7 @@ func (s *AntigravityGatewayService) handleChatCompletionsNonStreamingFromAntigra startTime time.Time, originalModel string, ) (*antigravityStreamResult, error) { - claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { return nil, s.mapAntigravityCompatCollectionError(c, err) } @@ -496,7 +499,7 @@ func (s *AntigravityGatewayService) handleResponsesNonStreamingFromAntigravity( startTime time.Time, originalModel string, ) (*antigravityStreamResult, error) { - claudeResponse, result, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResponse, result, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { return nil, s.mapAntigravityCompatCollectionError(c, err) } diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index 3243193ceb..26d30ddb46 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -301,6 +301,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix) } resetAntigravityCompatTimer(timeoutTimer, timeout) + s.observeAntigravityGeminiSSELine(c, event.line) session.consume(event.line) case <-timeoutCh: diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index f0d20244a8..77a7be3df7 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -42,6 +42,7 @@ func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOp } func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() forwardOpts := forwardGeminiOptions{} for _, apply := range options { @@ -90,7 +91,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel)) } - billingModel := mappedModel + forwardedModel := mappedModel // 获取 access_token if s.tokenProvider == nil { @@ -203,6 +204,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if err == nil && fallbackResp.StatusCode < 400 { _ = resp.Body.Close() resp = fallbackResp + forwardedModel = fallbackModel } else if fallbackResp != nil { _ = fallbackResp.Body.Close() } @@ -431,17 +433,19 @@ handleSuccess: } return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: billingModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: forwardedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index 02c2d85e36..00fee73a11 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -200,13 +200,18 @@ func (c *recordingInternal500CounterCache) ResetInternal500Count(_ context.Conte return nil } -type antigravitySettingRepoStub struct{} +type antigravitySettingRepoStub struct { + values map[string]string +} func (s *antigravitySettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { panic("unexpected Get call") } func (s *antigravitySettingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + if value, ok := s.values[key]; ok { + return value, nil + } return "", ErrSettingNotFound } @@ -862,6 +867,73 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing require.Equal(t, mappedModel, result.UpstreamModel) } +func TestAntigravityGatewayService_ForwardGemini_FallbackReportsActualUpstreamModel(t *testing.T) { + gin.SetMode(gin.TestMode) + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + + body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-primary:generateContent", bytes.NewReader(body)) + + const ( + originalModel = "gemini-primary" + mappedModel = "gemini-primary-upstream" + fallbackModel = "gemini-fallback-upstream" + ) + upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":404,"message":"model not found"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + `data: {"response":{"modelVersion":"gemini-fallback-upstream","candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3}}}` + "\n\n", + )), + }, + }} + settings := &antigravitySettingRepoStub{values: map[string]string{ + SettingKeyEnableModelFallback: "true", + SettingKeyFallbackModelAntigravity: fallbackModel, + }} + svc := &AntigravityGatewayService{ + settingService: NewSettingService(settings, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}), + tokenProvider: &AntigravityTokenProvider{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 9, + Name: "acc-gemini-fallback", + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "token", + "project_id": "proj", + "model_mapping": map[string]any{ + originalModel: mappedModel, + }, + }, + } + + result, err := svc.ForwardGemini(context.Background(), c, account, originalModel, "generateContent", true, body, false) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, originalModel, result.Model) + require.Equal(t, fallbackModel, result.UpstreamModel) + require.Equal(t, fallbackModel, result.UpstreamResponseModel) + require.False(t, result.UpstreamResponseModelConflict) + mismatch := upstreamModelMismatch(result.UpstreamModel, result.UpstreamResponseModel) + require.NotNil(t, mismatch) + require.False(t, *mismatch) + require.Len(t, upstream.requestBodies, 2) + require.Contains(t, string(upstream.requestBodies[0]), `"model":"`+mappedModel+`"`) + require.Contains(t, string(upstream.requestBodies[1]), `"model":"`+fallbackModel+`"`) +} + func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) { gin.SetMode(gin.TestMode) writer := httptest.NewRecorder() diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 2810162c45..2b68fc115a 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -22,6 +22,26 @@ type antigravityStreamResult struct { clientDisconnect bool // 客户端是否在流式传输过程中断开 } +func (s *AntigravityGatewayService) observeAntigravityGeminiSSELine(c *gin.Context, line string) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "data:") { + return + } + payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if payload == "" || payload == "[DONE]" { + return + } + // Observe the original payload: ObserveGemini supports both the v1internal + // wrapper and direct Gemini response shapes. The main stream handler will + // unwrap the same line for business processing, so unwrapping here would be + // duplicate work on every SSE event. + observer.ObserveGemini([]byte(payload)) +} + // antigravityClientWriter 封装流式响应的客户端写入,自动检测断开并标记。 // 断开后所有写入操作变为 no-op,调用方通过 Disconnected() 判断是否继续 drain 上游。 type antigravityClientWriter struct { @@ -95,6 +115,9 @@ func handleStreamReadError(err error, clientDisconnected bool, prefix string) (d } func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } c.Status(resp.StatusCode) c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") @@ -220,6 +243,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context lastDataAt = time.Now() line := ev.line + s.observeAntigravityGeminiSSELine(c, line) trimmed := strings.TrimRight(line, "\r\n") if strings.HasPrefix(trimmed, "data:") { payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) @@ -298,6 +322,9 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context // handleGeminiStreamToNonStreaming 读取上游流式响应,合并为非流式响应返回给客户端 // Gemini 流式响应是增量的,需要累积所有 chunk 的内容 func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { @@ -377,6 +404,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont } line := ev.line + s.observeAntigravityGeminiSSELine(c, line) trimmed := strings.TrimRight(line, "\r\n") if !strings.HasPrefix(trimmed, "data:") { @@ -765,7 +793,10 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, // collectClaudeStreamResponse 收集上游流式响应,转换为 Claude 非流式格式返回 // 用于处理客户端非流式请求但上游只支持流式的情况 -func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) { +func (s *AntigravityGatewayService) collectClaudeStreamResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) ([]byte, *antigravityStreamResult, error) { + if upstreamResponseModelObserverFromContext(c) == nil { + beginUpstreamResponseModelObservation(c) + } scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 { @@ -860,6 +891,7 @@ func (s *AntigravityGatewayService) collectClaudeStreamResponse(resp *http.Respo if parseErr != nil { continue } + upstreamResponseModelObserverFromContext(c).ObserveGemini(inner) var parsed map[string]any if err := json.Unmarshal(inner, &parsed); err != nil { @@ -941,7 +973,7 @@ returnResponse: // handleClaudeStreamToNonStreaming 收集上游流式响应,转换为 Claude 非流式格式返回 // 用于处理客户端非流式请求但上游只支持流式的情况 func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) { - claudeResp, streamRes, err := s.collectClaudeStreamResponse(resp, startTime, originalModel) + claudeResp, streamRes, err := s.collectClaudeStreamResponse(c, resp, startTime, originalModel) if err != nil { var failoverErr *UpstreamFailoverError if errors.As(err, &failoverErr) { @@ -1120,6 +1152,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context } lastDataAt = time.Now() + s.observeAntigravityGeminiSSELine(c, ev.line) // 处理 SSE 行,转换为 Claude 格式 claudeEvents := processor.ProcessLine(strings.TrimRight(ev.line, "\r\n")) diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index 2914863131..7254745698 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -19,6 +19,7 @@ import ( // ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求 func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) startTime := time.Now() sessionID := getSessionID(c) prefix := logPrefix(sessionID, account.Name) @@ -129,6 +130,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. } // 提取 usage + upstreamResponseModelObserverFromContext(c).ObserveAnthropic(respBody) usage = s.extractClaudeUsage(respBody) c.Header("Content-Type", resp.Header.Get("Content-Type")) @@ -141,11 +143,13 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds()) return &ForwardResult{ - Model: originalModel, - Stream: claudeReq.Stream, - Duration: duration, - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + Model: originalModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: claudeReq.Stream, + Duration: duration, + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, Usage: ClaudeUsage{ InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, @@ -247,6 +251,9 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp lastDataAt = time.Now() line := ev.line + if data, ok := extractAnthropicSSEDataLine(line); ok { + upstreamResponseModelObserverFromContext(c).ObserveAnthropic([]byte(strings.TrimSpace(data))) + } // 记录首 token 时间 if firstTokenMs == nil && len(line) > 0 { diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 924fc20fcd..5fdc96b793 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -56,32 +56,37 @@ type APIKeyAuthUserSnapshot struct { // APIKeyAuthGroupSnapshot 分组快照 type APIKeyAuthGroupSnapshot struct { - ID int64 `json:"id"` - Name string `json:"name"` - Platform string `json:"platform"` - IsExclusive bool `json:"is_exclusive"` - Status string `json:"status"` - SubscriptionType string `json:"subscription_type"` - RateMultiplier float64 `json:"rate_multiplier"` - DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` - AllowImageGeneration bool `json:"allow_image_generation"` - AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier float64 `json:"image_rate_multiplier"` - ImagePrice1K *float64 `json:"image_price_1k,omitempty"` - ImagePrice2K *float64 `json:"image_price_2k,omitempty"` - ImagePrice4K *float64 `json:"image_price_4k,omitempty"` - VideoRateIndependent bool `json:"video_rate_independent"` - VideoRateMultiplier float64 `json:"video_rate_multiplier"` - VideoPrice480P *float64 `json:"video_price_480p,omitempty"` - VideoPrice720P *float64 `json:"video_price_720p,omitempty"` - VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` - WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` - ClaudeCodeOnly bool `json:"claude_code_only"` - FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` - FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + IsExclusive bool `json:"is_exclusive"` + Status string `json:"status"` + SubscriptionType string `json:"subscription_type"` + RateMultiplier float64 `json:"rate_multiplier"` + DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` + AllowImageGeneration bool `json:"allow_image_generation"` + AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + ImagePrice1K *float64 `json:"image_price_1k,omitempty"` + ImagePrice2K *float64 `json:"image_price_2k,omitempty"` + ImagePrice4K *float64 `json:"image_price_4k,omitempty"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` + VideoPrice480P *float64 `json:"video_price_480p,omitempty"` + VideoPrice720P *float64 `json:"video_price_720p,omitempty"` + VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` + VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"` + SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"` + 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"` + ClaudeCodeOnly bool `json:"claude_code_only"` + FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` + FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` // Model routing is used by gateway account selection, so it must be part of auth cache snapshot. // Only anthropic groups use these fields; others may leave them empty. @@ -114,6 +119,17 @@ type APIKeyAuthGroupSnapshot struct { PeakStart string `json:"peak_start"` PeakEnd string `json:"peak_end"` PeakRateMultiplier float64 `json:"peak_rate_multiplier"` + + // 分组利润控制:调度准入门在直连热路径上读的就是这份快照——门解析 + // (resolveOpenAIProfitControlGate / resolveProfitControlGroup)优先取 + // 认证中间件放入 ctx 的 Group,而它正是本快照物化出来的对象,生产绝大 + // 多数流量走的都是这条路;只有 composite/模型路由等被调度分组与认证分组 + // 不一致时才回源 schedulerSnapshot。 + // 因此这三个字段与 GetByKeyForAuth 的投影都不得删减:漏掉任何一个, + // 门会拿到零值 ProfitControlEnabled=false 而静默失效(有集成测试兜底)。 + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` } // APIKeyAuthCacheEntry 缓存条目,支持负缓存 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index fd5fda22c7..696f21bb25 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 = 17 // v17: include the OpenAI group Live gate +const apiKeyAuthSnapshotVersion = 19 // v19: group search/audio/video_model_prices billing fields (force refresh of pre-fix snapshots) type apiKeyAuthCacheConfig struct { l1Size int @@ -400,7 +400,12 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) VideoPrice480P: apiKey.Group.VideoPrice480P, VideoPrice720P: apiKey.Group.VideoPrice720P, VideoPrice1080P: apiKey.Group.VideoPrice1080P, + VideoModelPrices: NormalizeVideoModelPrices(apiKey.Group.VideoModelPrices), WebSearchPricePerCall: apiKey.Group.WebSearchPricePerCall, + SearchPricePer1k: apiKey.Group.SearchPricePer1k, + AudioRealtimePricePerMin: apiKey.Group.AudioRealtimePricePerMin, + AudioTTSPricePerMillionChars: apiKey.Group.AudioTTSPricePerMillionChars, + AudioSTTPricePerHour: apiKey.Group.AudioSTTPricePerHour, ClaudeCodeOnly: apiKey.Group.ClaudeCodeOnly, FallbackGroupID: apiKey.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: apiKey.Group.FallbackGroupIDOnInvalidRequest, @@ -420,6 +425,9 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) PeakStart: apiKey.Group.PeakStart, PeakEnd: apiKey.Group.PeakEnd, PeakRateMultiplier: apiKey.Group.PeakRateMultiplier, + ProfitControlEnabled: apiKey.Group.ProfitControlEnabled, + ProfitMinMargin: apiKey.Group.ProfitMinMargin, + ProfitSafetyBuffer: apiKey.Group.ProfitSafetyBuffer, } } return snapshot @@ -487,7 +495,12 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho VideoPrice480P: snapshot.Group.VideoPrice480P, VideoPrice720P: snapshot.Group.VideoPrice720P, VideoPrice1080P: snapshot.Group.VideoPrice1080P, + VideoModelPrices: NormalizeVideoModelPrices(snapshot.Group.VideoModelPrices), WebSearchPricePerCall: snapshot.Group.WebSearchPricePerCall, + SearchPricePer1k: snapshot.Group.SearchPricePer1k, + AudioRealtimePricePerMin: snapshot.Group.AudioRealtimePricePerMin, + AudioTTSPricePerMillionChars: snapshot.Group.AudioTTSPricePerMillionChars, + AudioSTTPricePerHour: snapshot.Group.AudioSTTPricePerHour, ClaudeCodeOnly: snapshot.Group.ClaudeCodeOnly, FallbackGroupID: snapshot.Group.FallbackGroupID, FallbackGroupIDOnInvalidRequest: snapshot.Group.FallbackGroupIDOnInvalidRequest, @@ -507,6 +520,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho PeakStart: snapshot.Group.PeakStart, PeakEnd: snapshot.Group.PeakEnd, PeakRateMultiplier: snapshot.Group.PeakRateMultiplier, + ProfitControlEnabled: snapshot.Group.ProfitControlEnabled, + ProfitMinMargin: snapshot.Group.ProfitMinMargin, + ProfitSafetyBuffer: snapshot.Group.ProfitSafetyBuffer, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go new file mode 100644 index 0000000000..40519f2896 --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -0,0 +1,93 @@ +package service + +// 投影漏列回归(service 半程):认证快照 build → L2 JSON 序列化 +// → 反序列化 → 还原 apiKey.Group → 请求 ctx → 利润门解析,全链路保真。 +// repository 半程(真实 GetByKeyForAuth 投影)见 +// internal/repository/api_key_repo_profit_projection_integration_test.go。 + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +func profitAuthTestAPIKey() *APIKey { + groupID := int64(50) + return &APIKey{ + ID: 82, + UserID: 40, + GroupID: &groupID, + Name: "profit-auth-roundtrip", + Status: StatusActive, + User: &User{ + ID: 40, + Email: "profit@test.local", + Status: StatusActive, + Concurrency: 5, + }, + Group: &Group{ + ID: groupID, + Name: "VIP-roundtrip", + Platform: PlatformOpenAI, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 0.06, + SubscriptionType: SubscriptionTypeStandard, + PeakRateEnabled: false, + ProfitControlEnabled: true, + ProfitMinMargin: 0.2, + ProfitSafetyBuffer: 0.05, + }, + } +} + +// 快照构建 → L2 JSON 往返 → 还原 → 装门:利润字段必须全程保真,阈值与 +// 计费同源(0.06 × (1−0.25) = 0.045)。 +func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { + svc := &APIKeyService{} + apiKey := profitAuthTestAPIKey() + + 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 计费字段") + + // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) + require.NoError(t, err) + var restored APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &restored)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &restored) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.Hydrated) + require.True(t, materialized.Group.ProfitControlEnabled) + require.InDelta(t, 0.2, materialized.Group.ProfitMinMargin, 1e-12) + require.InDelta(t, 0.05, materialized.Group.ProfitSafetyBuffer, 1e-12) + require.InDelta(t, 0.06, materialized.Group.RateMultiplier, 1e-12) + + // 中间件语义:materialized.Group 进请求 ctx → 门必须按快照配置装上。 + ctx := context.WithValue(context.Background(), ctxkey.Group, materialized.Group) + gwSvc := &OpenAIGatewayService{} + gate := gwSvc.resolveOpenAIProfitControlGate(ctx, materialized.GroupID) + require.NotNil(t, gate, "还原后的认证分组必须能装门(投影漏列时本断言最先失败)") + require.InDelta(t, 0.06*(1-0.25), gate.threshold, 1e-12) +} + +// 旧版本快照(v16 及更早,无利润字段保真保证)必须被淘汰回源,不得复用。 +func TestAPIKeyAuthSnapshotOldVersionEvicted(t *testing.T) { + svc := &APIKeyService{} + snapshot := svc.snapshotFromAPIKey(context.Background(), profitAuthTestAPIKey()) + require.NotNil(t, snapshot) + snapshot.Version = 16 + + materialized, used, err := svc.applyAuthCacheEntry("sk-old", &APIKeyAuthCacheEntry{Snapshot: snapshot}) + require.NoError(t, err) + require.False(t, used, "版本不匹配的缓存条目必须淘汰并回源重建") + require.Nil(t, materialized) +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 2197912c9a..8ce62a688f 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "html" + "math" "sort" "strconv" "strings" @@ -245,6 +246,36 @@ type UpdateAPIKeyRequest struct { ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // Reset all usage counters to 0 } +func validateAPIKeyLimit(v float64) error { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return infraerrors.BadRequest("API_KEY_LIMIT_INVALID", "API key limits must be finite and non-negative") + } + return nil +} + +func validateCreateAPIKeyRequest(req CreateAPIKeyRequest) error { + for _, v := range []float64{req.Quota, req.RateLimit5h, req.RateLimit1d, req.RateLimit7d} { + if err := validateAPIKeyLimit(v); err != nil { + return err + } + } + if req.ExpiresInDays != nil && *req.ExpiresInDays <= 0 { + return infraerrors.BadRequest("API_KEY_EXPIRY_INVALID", "expires_in_days must be greater than zero") + } + return nil +} + +func validateUpdateAPIKeyRequest(req UpdateAPIKeyRequest) error { + for _, v := range []*float64{req.Quota, req.RateLimit5h, req.RateLimit1d, req.RateLimit7d} { + if v != nil { + if err := validateAPIKeyLimit(*v); err != nil { + return err + } + } + } + return nil +} + // APIKeyService API Key服务 // RateLimitCacheInvalidator invalidates rate limit cache entries on manual reset. type RateLimitCacheInvalidator interface { @@ -428,6 +459,9 @@ func (s *APIKeyService) canUserBindGroup(ctx context.Context, user *User, group // Create 创建API Key func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIKeyRequest) (*APIKey, error) { + if err := validateCreateAPIKeyRequest(req); err != nil { + return nil, err + } // 验证用户存在 user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -723,6 +757,9 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro // Update 更新API Key func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req UpdateAPIKeyRequest) (*APIKey, error) { + if err := validateUpdateAPIKeyRequest(req); err != nil { + return nil, err + } apiKey, err := s.apiKeyRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("get api key: %w", err) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 75f4a0de9c..31f62adfee 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -296,8 +296,8 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi }, Group: &Group{ ID: groupID, - Name: "openai", - Platform: PlatformOpenAI, + Name: "composite", + Platform: PlatformComposite, Status: StatusActive, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1, @@ -313,6 +313,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi require.NotNil(t, roundTrip) require.NotNil(t, roundTrip.Group) + require.Equal(t, PlatformComposite, roundTrip.Group.Platform) require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort) require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings) } diff --git a/backend/internal/service/api_key_service_validation_test.go b/backend/internal/service/api_key_service_validation_test.go new file mode 100644 index 0000000000..fc15c0b9a6 --- /dev/null +++ b/backend/internal/service/api_key_service_validation_test.go @@ -0,0 +1,46 @@ +//go:build unit + +package service + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateCreateAPIKeyRequestNumericLimits(t *testing.T) { + positiveExpiry := 1 + require.NoError(t, validateCreateAPIKeyRequest(CreateAPIKeyRequest{ + Quota: 1e100, RateLimit5h: 1e100, ExpiresInDays: &positiveExpiry, + })) + require.NoError(t, validateCreateAPIKeyRequest(CreateAPIKeyRequest{})) + + invalidExpiry := 0 + tests := []CreateAPIKeyRequest{ + {Quota: -1}, + {Quota: math.NaN()}, + {Quota: math.Inf(1)}, + {RateLimit5h: -1}, + {RateLimit1d: math.NaN()}, + {RateLimit7d: math.Inf(-1)}, + {ExpiresInDays: &invalidExpiry}, + } + for _, req := range tests { + require.Error(t, validateCreateAPIKeyRequest(req)) + } +} + +func TestValidateUpdateAPIKeyRequestNumericLimits(t *testing.T) { + zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(1) + require.NoError(t, validateUpdateAPIKeyRequest(UpdateAPIKeyRequest{Quota: &zero, RateLimit7d: &large})) + + for _, req := range []UpdateAPIKeyRequest{ + {Quota: &negative}, + {RateLimit5h: &nan}, + {RateLimit1d: &inf}, + {RateLimit7d: &negative}, + } { + require.Error(t, validateUpdateAPIKeyRequest(req)) + } +} diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index af63f7c433..300bb71721 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -42,6 +42,9 @@ func (s *AuthService) SendPendingOAuthVerifyCode(ctx context.Context, email stri if s == nil || s.emailService == nil { return nil, ErrServiceUnavailable } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + return nil, err + } siteName := "Sub2API" if s.settingService != nil { @@ -118,10 +121,6 @@ func (s *AuthService) RegisterOAuthEmailAccount( if isReservedEmail(email) { return nil, nil, ErrEmailReserved } - if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { - slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error()) - return nil, nil, err - } if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil { slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error()) return nil, nil, err @@ -141,6 +140,10 @@ func (s *AuthService) RegisterOAuthEmailAccount( if existsEmail { return nil, nil, ErrEmailExists } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error()) + return nil, nil, err + } hashedPassword, err := s.HashPassword(password) if err != nil { @@ -160,12 +163,16 @@ func (s *AuthService) RegisterOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { - if errors.Is(err, ErrEmailExists) { + if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil { + switch { + case errors.Is(err, ErrEmailExists): return nil, nil, ErrEmailExists + case errors.Is(err, ErrEmailDomainRegistrationLimit): + return nil, nil, ErrEmailDomainRegistrationLimit + default: + slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error()) + return nil, nil, ErrServiceUnavailable } - slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error()) - return nil, nil, ErrServiceUnavailable } tokenPair, err := s.GenerateTokenPair(ctx, user, "") @@ -202,9 +209,6 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( if isReservedEmail(email) { return nil, nil, ErrEmailReserved } - if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { - return nil, nil, err - } if strings.TrimSpace(password) == "" { return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required") } @@ -220,6 +224,9 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( if existsEmail { return nil, nil, ErrEmailExists } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + return nil, nil, err + } hashedPassword, err := s.HashPassword(password) if err != nil { @@ -243,11 +250,15 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { - if errors.Is(err, ErrEmailExists) { + if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil { + switch { + case errors.Is(err, ErrEmailExists): return nil, nil, ErrEmailExists + case errors.Is(err, ErrEmailDomainRegistrationLimit): + return nil, nil, ErrEmailDomainRegistrationLimit + default: + return nil, nil, ErrServiceUnavailable } - return nil, nil, ErrServiceUnavailable } tokenPair, err := s.GenerateTokenPair(ctx, user, "") diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go index 5a78a348d3..6e57e95016 100644 --- a/backend/internal/service/auth_oauth_email_flow_test.go +++ b/backend/internal/service/auth_oauth_email_flow_test.go @@ -199,6 +199,109 @@ func TestRegisterOAuthEmailAccountRollsBackCreatedUserWhenTokenPairGenerationFai require.Empty(t, redeemRepo.updateCalls) } +func TestRegisterOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) { + userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + authService := newOAuthEmailFlowAuthService( + userRepo, + &redeemCodeRepoStub{}, + &refreshTokenCacheStub{}, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, + &emailCacheStub{data: &VerificationCodeData{ + Code: "246810", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }}, + nil, + ) + + _, _, err := authService.RegisterOAuthEmailAccount( + context.Background(), + "second@custom.example", + "secret-123", + "246810", + "", + "oidc", + ) + + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) +} + +func TestRegisterVerifiedOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) { + userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + authService := newOAuthEmailFlowAuthService( + userRepo, + nil, + &refreshTokenCacheStub{}, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, + &emailCacheStub{}, + nil, + ) + + _, _, err := authService.RegisterVerifiedOAuthEmailAccount( + context.Background(), + "second@custom.example", + "secret-123", + "", + "oidc", + ) + + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) +} + +func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) { + userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + authService := newOAuthEmailFlowAuthService( + userRepo, + nil, + nil, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, + &emailCacheStub{}, + nil, + ) + + _, err := authService.SendPendingOAuthVerifyCode(context.Background(), "second@custom.example") + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) +} + +// 域名限量注册开关默认关闭:白名单外域名在 pending OAuth 发码阶段即被严格拒绝。 +func TestSendPendingOAuthVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + authService := newOAuthEmailFlowAuthService( + userRepo, + nil, + nil, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, + &emailCacheStub{}, + nil, + ) + + _, err := authService.SendPendingOAuthVerifyCode(context.Background(), "first@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + +func TestSendPendingOAuthVerifyCode_NilServiceReturnsUnavailable(t *testing.T) { + var authService *AuthService + + _, err := authService.SendPendingOAuthVerifyCode(context.Background(), "fresh@example.com") + + require.ErrorIs(t, err, ErrServiceUnavailable) +} + func TestRegisterOAuthEmailAccountSetsNormalizedSignupSourceOnCreatedUser(t *testing.T) { userRepo := &userRepoStub{nextID: 42} emailCache := &emailCacheStub{ diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 5617f5f4ec..2beba0d402 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -24,25 +24,30 @@ import ( ) var ( - ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") - ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") - ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") - ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") - ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") - ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") - ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired") - ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") - ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") - ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token") - ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") - ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") - ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") - ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed") + ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") + ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") + ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") + ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") + ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") + ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") + ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired") + ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") + ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") + ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token") + ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired") + ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused") + ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") + ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed") + ErrEmailDomainRegistrationLimit = infraerrors.BadRequest( + "EMAIL_DOMAIN_REGISTRATION_LIMIT", + "this email domain cannot register another account; use a mainstream email or contact support to add the enterprise domain", + ) ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration") + ErrCaptchaProviderConflict = infraerrors.ServiceUnavailable("CAPTCHA_PROVIDER_CONFLICT", "multiple captcha providers are enabled") ) // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 @@ -74,6 +79,8 @@ type AuthService struct { settingService *SettingService emailService *EmailService turnstileService *TurnstileService + tencentCaptchaService *TencentCaptchaService + aliyunCaptchaService *AliyunCaptchaService emailQueueService *EmailQueueService promoService *PromoService affiliateService *AffiliateService @@ -81,6 +88,13 @@ type AuthService struct { userPlatformQuotaRepo UserPlatformQuotaRepository } +type CaptchaProof struct { + // TurnstileToken 承载 Cloudflare Turnstile token;阿里云验证码复用该字段承载 captchaVerifyParam + TurnstileToken string + TencentTicket string + TencentRandstr string +} + type DefaultSubscriptionAssigner interface { AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) } @@ -132,6 +146,14 @@ func (s *AuthService) EntClient() *dbent.Client { return s.entClient } +func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCaptchaService) { + s.tencentCaptchaService = tencentCaptchaService +} + +func (s *AuthService) SetAliyunCaptchaService(aliyunCaptchaService *AliyunCaptchaService) { + s.aliyunCaptchaService = aliyunCaptchaService +} + // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { return s.RegisterWithVerification(ctx, email, password, "", "", "", "") @@ -148,10 +170,6 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw if isReservedEmail(email) { return "", nil, ErrEmailReserved } - if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { - return "", nil, err - } - // 检查是否需要邀请码 var invitationRedeemCode *RedeemCode if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) { @@ -198,6 +216,9 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw if existsEmail { return "", nil, ErrEmailExists } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + return "", nil, err + } // 密码哈希 hashedPassword, err := s.HashPassword(password) @@ -224,13 +245,17 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw Status: StatusActive, } - if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { + if err := s.createUserWithRegistrationEmailGuard(ctx, user); err != nil { // 优先检查邮箱冲突错误(竞态条件下可能发生) - if errors.Is(err, ErrEmailExists) { + switch { + case errors.Is(err, ErrEmailExists): return "", nil, ErrEmailExists + case errors.Is(err, ErrEmailDomainRegistrationLimit): + return "", nil, ErrEmailDomainRegistrationLimit + default: + logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err) + return "", nil, ErrServiceUnavailable } - logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err) - return "", nil, ErrServiceUnavailable } s.postAuthUserBootstrap(ctx, user, "email", true) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") @@ -292,10 +317,6 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale . if isReservedEmail(email) { return ErrEmailReserved } - if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { - return err - } - // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { @@ -305,6 +326,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale . if existsEmail { return ErrEmailExists } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + return err + } // 发送验证码 if s.emailService == nil { @@ -333,10 +357,6 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc if isReservedEmail(email) { return nil, ErrEmailReserved } - if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { - return nil, err - } - // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化;在发信前拦截,避免批量脚本消耗发信配额) existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { @@ -347,6 +367,9 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc logger.LegacyPrintf("service.auth", "[Auth] Email already exists: %s", email) return nil, ErrEmailExists } + if err := s.validateRegistrationEmailQuota(ctx, email); err != nil { + return nil, err + } // 检查邮件队列服务是否配置 if s.emailQueueService == nil { @@ -373,47 +396,118 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc }, nil } -// VerifyTurnstileForRegister 在注册场景下验证 Turnstile。 -// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成 Turnstile 校验, +// VerifyCaptchaForRegister 在注册场景下验证当前启用的验证码。 +// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成验证码校验, // 此处跳过二次校验,避免一次性 token 在注册提交时重复使用导致误报失败。 -func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error { +func (s *AuthService) VerifyCaptchaForRegister(ctx context.Context, proof CaptchaProof, remoteIP, verifyCode string) error { if s.IsEmailVerifyEnabled(ctx) && strings.TrimSpace(verifyCode) != "" { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate Turnstile check on register") + logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate captcha check on register") return nil } - return s.VerifyTurnstile(ctx, token, remoteIP) + return s.VerifyCaptcha(ctx, proof, remoteIP) } -// VerifyTurnstile 验证Turnstile token -func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error { +func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, remoteIP string) error { required := s.cfg != nil && s.cfg.Server.Mode == "release" && s.cfg.Turnstile.Required - - if required { - if s.settingService == nil { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but settings service is not configured") - return ErrTurnstileNotConfigured - } - enabled := s.settingService.IsTurnstileEnabled(ctx) - secretConfigured := s.settingService.GetTurnstileSecretKey(ctx) != "" - if !enabled || !secretConfigured { - logger.LegacyPrintf("service.auth", "[Auth] Turnstile required but not configured (enabled=%v, secret_configured=%v)", enabled, secretConfigured) - return ErrTurnstileNotConfigured - } - } - - if s.turnstileService == nil { + if s.settingService == nil { if required { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but service not configured") return ErrTurnstileNotConfigured } - return nil // 服务未配置则跳过验证 + return nil } - if !required && s.settingService != nil && s.settingService.IsTurnstileEnabled(ctx) && s.settingService.GetTurnstileSecretKey(ctx) == "" { - logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile enabled but secret key not configured") + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") + return ErrServiceUnavailable + } + turnstileEnabled := providerConfig.TurnstileEnabled + tencentEnabled := providerConfig.Tencent.Enabled + aliyunEnabled := providerConfig.Aliyun.Enabled + if captchaProvidersConflict(turnstileEnabled, tencentEnabled, aliyunEnabled) { + return ErrCaptchaProviderConflict + } + if tencentEnabled { + if s.tencentCaptchaService == nil { + return ErrTencentCaptchaNotConfigured + } + return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP) + } + if aliyunEnabled { + if s.aliyunCaptchaService == nil { + return ErrAliyunCaptchaNotConfigured + } + return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) + } + if turnstileEnabled { + if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" { + return ErrTurnstileNotConfigured + } + return s.turnstileService.VerifyTokenWithSecret(ctx, providerConfig.TurnstileSecretKey, proof.TurnstileToken, remoteIP) + } + if required { + return ErrTurnstileNotConfigured + } + return nil +} + +// captchaProvidersConflict 同一时间仅允许启用一家人机验证服务商 +func captchaProvidersConflict(enabled ...bool) bool { + count := 0 + for _, e := range enabled { + if e { + count++ + } + } + return count > 1 +} + +// VerifyActionCaptchaIfEnabled 仅保护动作触发的扩展入口(OAuth 登录启动、passkey 登录), +// 腾讯天御与阿里云验证码启用时拦截;不扩大 Cloudflare Turnstile 的既有覆盖范围。 +func (s *AuthService) VerifyActionCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error { + if s == nil || s.settingService == nil { + return ErrServiceUnavailable } - return s.turnstileService.VerifyToken(ctx, token, remoteIP) + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings") + return ErrServiceUnavailable + } + tencentEnabled := providerConfig.Tencent.Enabled + aliyunEnabled := providerConfig.Aliyun.Enabled + if !tencentEnabled && !aliyunEnabled { + return nil + } + if captchaProvidersConflict(providerConfig.TurnstileEnabled, tencentEnabled, aliyunEnabled) { + return ErrCaptchaProviderConflict + } + if aliyunEnabled { + if s.aliyunCaptchaService == nil { + return ErrAliyunCaptchaNotConfigured + } + return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken) + } + if s.tencentCaptchaService == nil { + return ErrTencentCaptchaNotConfigured + } + return s.tencentCaptchaService.VerifyTicketWithConfig( + ctx, + providerConfig.Tencent, + proof.TencentTicket, + proof.TencentRandstr, + remoteIP, + ) +} + +// VerifyTurnstileForRegister 保留旧内部接口,生产 handler 使用 VerifyCaptchaForRegister。 +func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error { + return s.VerifyCaptchaForRegister(ctx, CaptchaProof{TurnstileToken: token}, remoteIP, verifyCode) +} + +// VerifyTurnstile 保留旧内部接口,生产 handler 使用 VerifyCaptcha。 +func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error { + return s.VerifyCaptcha(ctx, CaptchaProof{TurnstileToken: token}, remoteIP) } // IsTurnstileEnabled 检查是否启用Turnstile验证 @@ -1112,6 +1206,74 @@ func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email return nil } +// validateRegistrationEmailQuota 保留白名单为空时的全放行行为;配置白名单后, +// 非白名单域名默认直接拒绝(严格白名单模式);仅当域名限量注册开关开启时, +// 非白名单域名每个最多允许一个账户。 +func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email string) error { + if s.settingService == nil { + return nil + } + whitelist := s.settingService.GetRegistrationEmailSuffixWhitelist(ctx) + if !IsRegistrationEmailSuffixLimited(email, whitelist) { + return nil + } + if !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { + return buildEmailSuffixNotAllowedError(whitelist) + } + + domain := RegistrationEmailDomain(email) + if domain == "" { + return buildEmailSuffixNotAllowedError(whitelist) + } + quotaRepo, ok := s.userRepo.(RegistrationEmailDomainRepository) + if !ok { + // 生产装配必须提供原子仓储能力;没有数据库的 unit 测试桩保留旧路径, + // 避免无关测试被注册专用依赖干扰。 + if s.entClient != nil { + return ErrServiceUnavailable + } + return nil + } + count, err := quotaRepo.CountUsersByEmailDomain(ctx, domain) + if err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to count registration email domain %s: %v", domain, err) + return ErrServiceUnavailable + } + if count > 0 { + return ErrEmailDomainRegistrationLimit + } + return nil +} + +func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context, user *User) error { + if s == nil || s.userRepo == nil { + return ErrServiceUnavailable + } + whitelist := []string{} + if s.settingService != nil { + whitelist = s.settingService.GetRegistrationEmailSuffixWhitelist(ctx) + } + domain := RegistrationEmailDomain(user.Email) + if !IsRegistrationEmailSuffixLimited(user.Email, whitelist) { + return s.userRepo.CreateWithEmailAliasGuard(ctx, user) + } + // 开关关闭时非白名单域名在校验阶段已被拒绝;此处兜底防御设置竞态变更。 + if s.settingService == nil || !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) { + return buildEmailSuffixNotAllowedError(whitelist) + } + if domain == "" { + return buildEmailSuffixNotAllowedError(whitelist) + } + quotaRepo, ok := s.userRepo.(RegistrationEmailDomainRepository) + if !ok { + if s.entClient != nil { + return ErrServiceUnavailable + } + return s.userRepo.CreateWithEmailAliasGuard(ctx, user) + } + return quotaRepo.CreateWithEmailAliasGuardAndDomainLimit(ctx, user, domain) +} + func buildEmailSuffixNotAllowedError(whitelist []string) error { if len(whitelist) == 0 { return ErrEmailSuffixNotAllowed diff --git a/backend/internal/service/auth_service_captcha_test.go b/backend/internal/service/auth_service_captcha_test.go new file mode 100644 index 0000000000..343ff4ca55 --- /dev/null +++ b/backend/internal/service/auth_service_captcha_test.go @@ -0,0 +1,188 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newAuthServiceForCaptchaRepoTest(repo *settingRepoStub, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: required}, + } + settingService := NewSettingService(repo, cfg) + turnstileService := NewTurnstileService(settingService, turnstileVerifier) + tencentService := NewTencentCaptchaService(settingService, tencentVerifier) + svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil) + svc.SetTencentCaptchaService(tencentService) + return svc +} + +func newAuthServiceForCaptchaTest(settings map[string]string, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService { + cfg := &config.Config{ + Server: config.ServerConfig{Mode: "release"}, + Turnstile: config.TurnstileConfig{Required: required}, + } + settingService := NewSettingService(&settingRepoStub{values: settings}, cfg) + var turnstileService *TurnstileService + if turnstileVerifier != nil { + turnstileService = NewTurnstileService(settingService, turnstileVerifier) + } + svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil) + if tencentVerifier != nil { + svc.SetTencentCaptchaService(NewTencentCaptchaService(settingService, tencentVerifier)) + } + return svc +} + +func tencentCaptchaSettings() map[string]string { + return map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + } +} + +func TestVerifyCaptchaUsesTencentWhenEnabled(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) +} + +func TestVerifyCaptchaRejectsDirtyDoubleEnabledSettings(t *testing.T) { + settings := tencentCaptchaSettings() + settings[SettingKeyTurnstileEnabled] = "true" + settings[SettingKeyTurnstileSecretKey] = "turnstile-secret" + turnstileVerifier := &turnstileVerifierSpy{} + tencentVerifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, tencentVerifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TurnstileToken: "turnstile-token", + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.ErrorIs(t, err, ErrCaptchaProviderConflict) + require.Zero(t, turnstileVerifier.called) + require.Zero(t, tencentVerifier.calls) +} + +func TestVerifyCaptchaRequiredModeAcceptsCompleteTencentProvider(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), true, nil, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) +} + +func TestVerifyCaptchaForRegisterSkipsDuplicateTencentTicketAfterEmailCode(t *testing.T) { + settings := tencentCaptchaSettings() + settings[SettingKeyEmailVerifyEnabled] = "true" + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(settings, true, nil, verifier) + + err := svc.VerifyCaptchaForRegister(context.Background(), CaptchaProof{}, "203.0.113.10", "123456") + + require.NoError(t, err) + require.Zero(t, verifier.calls) +} + +func TestVerifyCaptchaFailsClosedWhenProviderSettingsCannotBeRead(t *testing.T) { + repo := &settingRepoStub{err: errors.New("settings unavailable")} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{}) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) +} + +func TestVerifyCaptchaReadsProviderConfigurationOnce(t *testing.T) { + repo := &settingRepoStub{values: tencentCaptchaSettings()} + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, repo.getMultipleCalls) + require.Zero(t, repo.getValueCalls) + require.Equal(t, 1, verifier.calls) +} + +func TestVerifyCaptchaRejectsEnabledTencentProviderWithIncompleteCredentials(t *testing.T) { + repo := &settingRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + }} + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier) + + err := svc.VerifyCaptcha(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaNotConfigured) + require.Equal(t, 1, repo.getMultipleCalls) + require.Zero(t, verifier.calls) +} + +func TestVerifyActionCaptchaIfEnabledVerifiesTencentProof(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier) + + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{ + TencentTicket: "ticket", + TencentRandstr: "@rand", + }, "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) + require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof) +} + +func TestVerifyActionCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) { + settings := map[string]string{ + SettingKeyTurnstileEnabled: "true", + SettingKeyTurnstileSecretKey: "turnstile-secret", + } + turnstileVerifier := &turnstileVerifierSpy{} + svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, nil) + + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.NoError(t, err) + require.Zero(t, turnstileVerifier.called) +} + +func TestVerifyActionCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) { + repo := &settingRepoStub{err: errors.New("settings unavailable")} + svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{}) + + err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index eeb5f3b0a4..04d7efaeb9 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -14,8 +14,10 @@ import ( ) type settingRepoStub struct { - values map[string]string - err error + values map[string]string + err error + getValueCalls int + getMultipleCalls int } func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -23,6 +25,7 @@ func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) } func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + s.getValueCalls++ if s.err != nil { return "", s.err } @@ -37,6 +40,7 @@ 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.getMultipleCalls++ if s.err != nil { return nil, s.err } @@ -410,20 +414,120 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) { } func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) { - repo := &userRepoStub{} + repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}} service := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) _, _, err := service.Register(context.Background(), "user@other.com", "password") + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) + appErr := infraerrors.FromError(err) + require.Equal(t, "EMAIL_DOMAIN_REGISTRATION_LIMIT", appErr.Reason) + require.Contains(t, appErr.Message, "mainstream email") +} + +func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T) { + repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, nil, nil) + + _, user, err := svc.Register(context.Background(), "first@custom.example", "password") + require.NoError(t, err) + require.Equal(t, int64(9), user.ID) +} + +func TestAuthService_Register_NonWhitelistDomainRejectsSecondAccount(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "second@sub.custom.example", "password") + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) +} + +// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义, +// 即使该域名下还没有任何账户也不放行。 +func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaDisabledByDefault(t *testing.T) { + repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "first@custom.example", "password") require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) appErr := infraerrors.FromError(err) - require.Contains(t, appErr.Message, "@example.com") - require.Contains(t, appErr.Message, "@company.com") require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason) - require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"]) - require.Equal(t, "@example.com,@company.com", appErr.Metadata["allowed_suffixes"]) + require.Empty(t, repo.created) + require.Zero(t, repo.domainLimitedCreates) +} + +func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaExplicitlyDisabled(t *testing.T) { + repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "false", + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "first@custom.example", "password") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) + require.Empty(t, repo.created) +} + +// 开关关闭不影响白名单命中域名的正常注册。 +func TestAuthService_Register_WhitelistDomainAllowedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{nextID: 12} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, user, err := svc.Register(context.Background(), "user@example.com", "password") + require.NoError(t, err) + require.Equal(t, int64(12), user.ID) + require.Zero(t, repo.domainLimitedCreates) +} + +func TestAuthService_SendVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + err := svc.SendVerifyCode(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + +func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + }, nil, nil) + + _, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) +} + +func TestAuthService_Register_EmptyWhitelistAllowsAllDomains(t *testing.T) { + repo := &userRepoStub{nextID: 10} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `[]`, + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "any@custom.example", "password") + require.NoError(t, err) } func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) { @@ -440,18 +544,41 @@ func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) { } func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) { - repo := &userRepoStub{} + repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}} service := newAuthService(repo, map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", }, nil, nil) err := service.SendVerifyCode(context.Background(), "user@other.com") - require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) appErr := infraerrors.FromError(err) - require.Contains(t, appErr.Message, "@example.com") - require.Contains(t, appErr.Message, "@company.com") - require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"]) + require.Equal(t, "EMAIL_DOMAIN_REGISTRATION_LIMIT", appErr.Reason) +} + +func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, nil, nil) + + err := svc.SendVerifyCode(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) +} + +func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainLimit(t *testing.T) { + repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, nil, nil) + + _, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example") + require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit) } func TestAuthService_Register_CreateError(t *testing.T) { diff --git a/backend/internal/service/backup_archive.go b/backend/internal/service/backup_archive.go new file mode 100644 index 0000000000..bc591b6a3c --- /dev/null +++ b/backend/internal/service/backup_archive.go @@ -0,0 +1,106 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" +) + +const defaultBackupPartSizeBytes int64 = 4 * 1024 * 1024 * 1024 + +// BackupPart 描述一个 gzip 字节分卷。 +type BackupPart struct { + Index int `json:"index"` + S3Key string `json:"s3_key"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256,omitempty"` +} + +type localBackupPart struct { + Index int + Path string + SizeBytes int64 + SHA256 string +} + +func splitBackupFile(srcPath string, partSize int64) (parts []localBackupPart, err error) { + if partSize <= 0 { + return nil, fmt.Errorf("backup part size must be positive") + } + + src, err := os.Open(srcPath) + if err != nil { + return nil, fmt.Errorf("open backup archive: %w", err) + } + defer func() { + if closeErr := src.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close backup archive: %w", closeErr) + } + if err != nil { + paths := make([]string, 0, len(parts)) + for _, part := range parts { + paths = append(paths, part.Path) + } + _ = cleanupBackupFiles(paths...) + } + }() + + info, err := src.Stat() + if err != nil { + return nil, fmt.Errorf("stat backup archive: %w", err) + } + if info.Size() <= 0 { + return nil, errors.New("backup archive is empty") + } + + remaining := info.Size() + for index := 1; remaining > 0; index++ { + partFile, createErr := os.CreateTemp("", "sub2api-backup-part-*") + if createErr != nil { + return nil, fmt.Errorf("create backup part: %w", createErr) + } + partPath := partFile.Name() + partBytes := partSize + if remaining < partBytes { + partBytes = remaining + } + + hash := sha256.New() + written, copyErr := io.CopyN(io.MultiWriter(partFile, hash), src, partBytes) + closeErr := partFile.Close() + if copyErr != nil { + _ = os.Remove(partPath) + return nil, fmt.Errorf("write backup part %d: %w", index, copyErr) + } + if closeErr != nil { + _ = os.Remove(partPath) + return nil, fmt.Errorf("close backup part %d: %w", index, closeErr) + } + + parts = append(parts, localBackupPart{ + Index: index, + Path: partPath, + SizeBytes: written, + SHA256: hex.EncodeToString(hash.Sum(nil)), + }) + remaining -= written + } + + return parts, nil +} + +func cleanupBackupFiles(paths ...string) error { + var errs []error + for _, path := range paths { + if path == "" { + continue + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove %s: %w", path, err)) + } + } + return errors.Join(errs...) +} diff --git a/backend/internal/service/backup_archive_test.go b/backend/internal/service/backup_archive_test.go new file mode 100644 index 0000000000..4132d3fbb8 --- /dev/null +++ b/backend/internal/service/backup_archive_test.go @@ -0,0 +1,62 @@ +//go:build unit + +package service + +import ( + "bytes" + "crypto/sha256" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSplitBackupFile_ReassemblesExactBytes(t *testing.T) { + src := writeBackupArchiveFixture(t, []byte("0123456789abcdefg")) + parts, err := splitBackupFile(src, 5) + require.NoError(t, err) + require.Len(t, parts, 4) + + var got bytes.Buffer + for i, part := range parts { + require.Equal(t, i+1, part.Index) + require.LessOrEqual(t, part.SizeBytes, int64(5)) + data, readErr := os.ReadFile(part.Path) + require.NoError(t, readErr) + require.Equal(t, fmt.Sprintf("%x", sha256.Sum256(data)), part.SHA256) + got.Write(data) + } + require.Equal(t, []byte("0123456789abcdefg"), got.Bytes()) +} + +func TestSplitBackupFile_RejectsInvalidInput(t *testing.T) { + src := writeBackupArchiveFixture(t, []byte("data")) + + _, err := splitBackupFile(src, 0) + require.Error(t, err) + + empty := writeBackupArchiveFixture(t, nil) + _, err = splitBackupFile(empty, 5) + require.Error(t, err) + + _, err = splitBackupFile(filepathForMissingBackupArchive(t), 5) + require.Error(t, err) +} + +func writeBackupArchiveFixture(t *testing.T, content []byte) string { + t.Helper() + path := filepathForBackupArchive(t) + require.NoError(t, os.WriteFile(path, content, 0o600)) + return path +} + +func filepathForBackupArchive(t *testing.T) string { + t.Helper() + return t.TempDir() + "/archive.gz" +} + +func filepathForMissingBackupArchive(t *testing.T) string { + t.Helper() + return t.TempDir() + "/missing.gz" +} diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go index 14080ecde8..f130b3653a 100644 --- a/backend/internal/service/backup_service.go +++ b/backend/internal/service/backup_service.go @@ -3,10 +3,13 @@ package service import ( "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "os" "sort" "strings" "sync" @@ -26,7 +29,8 @@ const ( settingKeyBackupSchedule = "backup_schedule" settingKeyBackupRecords = "backup_records" - maxBackupRecords = 100 + maxBackupRecords = 100 + backupObjectCleanupTimeout = 2 * time.Minute ) var ( @@ -61,6 +65,7 @@ type DBDumper interface { // BackupObjectStore abstracts object storage for backup files type BackupObjectStore interface { Upload(ctx context.Context, key string, body io.Reader, contentType string) (sizeBytes int64, err error) + UploadFile(ctx context.Context, key string, filePath string, contentType string) (sizeBytes int64, err error) Download(ctx context.Context, key string) (io.ReadCloser, error) Delete(ctx context.Context, key string) error PresignURL(ctx context.Context, key string, expiry time.Duration) (string, error) @@ -98,21 +103,35 @@ type BackupScheduleConfig struct { // BackupRecord 备份记录 type BackupRecord struct { - ID string `json:"id"` - Status string `json:"status"` // pending, running, completed, failed - BackupType string `json:"backup_type"` // postgres - FileName string `json:"file_name"` - S3Key string `json:"s3_key"` - SizeBytes int64 `json:"size_bytes"` - TriggeredBy string `json:"triggered_by"` // manual, scheduled - ErrorMsg string `json:"error_message,omitempty"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at,omitempty"` - ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 - Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" - RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" - RestoreError string `json:"restore_error,omitempty"` - RestoredAt string `json:"restored_at,omitempty"` + ID string `json:"id"` + Status string `json:"status"` // pending, running, completed, failed + BackupType string `json:"backup_type"` // postgres + FileName string `json:"file_name"` + S3Key string `json:"s3_key"` + Parts []BackupPart `json:"parts,omitempty"` + SizeBytes int64 `json:"size_bytes"` + TriggeredBy string `json:"triggered_by"` // manual, scheduled + ErrorMsg string `json:"error_message,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` // 过期时间 + Progress string `json:"progress,omitempty"` // "dumping", "uploading", "" + RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed" + RestoreError string `json:"restore_error,omitempty"` + RestoredAt string `json:"restored_at,omitempty"` +} + +// BackupDownloadPart 描述一个可下载的备份分卷。 +type BackupDownloadPart struct { + Index int `json:"index"` + SizeBytes int64 `json:"size_bytes"` + URL string `json:"url"` +} + +// BackupDownloadResponse 是单文件和分卷下载响应的兼容表示。 +type BackupDownloadResponse struct { + URL string `json:"url,omitempty"` + Parts []BackupDownloadPart `json:"parts,omitempty"` } // BackupService 数据库备份恢复服务 @@ -142,10 +161,11 @@ type BackupService struct { cronSched *cron.Cron cronEntryID cron.EntryID - wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine - shuttingDown atomic.Bool // 阻止新备份启动 - bgCtx context.Context // 所有后台操作的 parent context - bgCancel context.CancelFunc // 取消所有活跃后台操作 + wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine + shuttingDown atomic.Bool // 阻止新备份启动 + bgCtx context.Context // 所有后台操作的 parent context + bgCancel context.CancelFunc // 取消所有活跃后台操作 + partSizeBytes int64 // 分卷阈值;生产使用 4 GiB,测试可注入更小值 } func NewBackupService( @@ -165,6 +185,7 @@ func NewBackupService( dumper: dumper, bgCtx: bgCtx, bgCancel: bgCancel, + partSizeBytes: defaultBackupPartSizeBytes, } } @@ -191,33 +212,57 @@ func (s *BackupService) Start() { } } -// recoverStaleRecords 启动时将孤立的 running 记录标记为 failed +// recoverStaleRecords 启动时将孤立的 running 记录标记为 failed,并清理已上传对象。 func (s *BackupService) recoverStaleRecords() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + loadCtx, loadCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer loadCancel() - records, err := s.loadRecords(ctx) + records, err := s.loadRecords(loadCtx) if err != nil { return } for i := range records { if records[i].Status == "running" { + staleRecord := records[i] records[i].Status = "failed" records[i].ErrorMsg = "interrupted by server restart" records[i].Progress = "" records[i].FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(ctx, &records[i]) + s.saveRecoveredRecord(&records[i]) + + if cleanupErr := s.cleanupStaleBackupObjects(&staleRecord); cleanupErr != nil { + records[i].ErrorMsg = fmt.Sprintf("interrupted by server restart; cleanup failed, manual deletion may be required: %v", cleanupErr) + s.saveRecoveredRecord(&records[i]) + logger.LegacyPrintf("service.backup", "[Backup] failed to clean stale backup objects for %s: %v", records[i].ID, cleanupErr) + } logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID) } if records[i].RestoreStatus == "running" { records[i].RestoreStatus = "failed" records[i].RestoreError = "interrupted by server restart" - _ = s.saveRecord(ctx, &records[i]) + s.saveRecoveredRecord(&records[i]) logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID) } } } +func (s *BackupService) saveRecoveredRecord(record *BackupRecord) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.saveRecord(ctx, record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存恢复后的备份记录失败 %s: %v", record.ID, err) + } +} + +func (s *BackupService) cleanupStaleBackupObjects(record *BackupRecord) error { + if len(backupObjectKeys(record)) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout) + defer cancel() + return s.deleteBackupObjects(ctx, record) +} + // Stop 停止定时备份并等待活跃操作完成 func (s *BackupService) Stop() { s.shuttingDown.Store(true) @@ -452,7 +497,7 @@ func (s *BackupService) runScheduledBackup() { // ─── 备份/恢复核心 ─── -// CreateBackup 创建全量数据库备份并上传到 S3(流式处理) +// CreateBackup 创建全量数据库备份并上传到 S3。 // expireDays: 备份过期天数,0=永不过期,默认14天 func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) { if s.shuttingDown.Load() { @@ -506,61 +551,27 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex ExpiresAt: expiresAt, } - // 流式执行: pg_dump -> gzip -> S3 upload - dumpReader, err := s.dumper.Dump(ctx) + archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx) if err != nil { record.Status = "failed" - record.ErrorMsg = fmt.Sprintf("pg_dump failed: %v", err) + record.ErrorMsg = err.Error() record.FinishedAt = time.Now().Format(time.RFC3339) _ = s.saveRecord(ctx, record) - return record, fmt.Errorf("pg_dump: %w", err) + return record, err } - - // 使用 io.Pipe 将 gzip 压缩数据流式传递给 S3 上传 - pr, pw := io.Pipe() - gzipDone := make(chan error, 1) - go func() { - defer func() { - if r := recover(); r != nil { - pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck - gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) - } - }() - gzWriter := gzip.NewWriter(pw) - var gzErr error - _, gzErr = io.Copy(gzWriter, dumpReader) - if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { - gzErr = closeErr - } - if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { - gzErr = closeErr - } - if gzErr != nil { - _ = pw.CloseWithError(gzErr) - } else { - _ = pw.Close() - } - gzipDone <- gzErr - }() - - contentType := "application/gzip" - sizeBytes, err := objectStore.Upload(ctx, s3Key, pr, contentType) - if err != nil { - _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 - gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 - record.Status = "failed" - errMsg := fmt.Sprintf("S3 upload failed: %v", err) - if gzErr != nil { - errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) - } - record.ErrorMsg = errMsg - record.FinishedAt = time.Now().Format(time.RFC3339) - _ = s.saveRecord(ctx, record) - return record, fmt.Errorf("backup upload: %w", err) - } - <-gzipDone // 确保 gzip goroutine 已退出 - + defer func() { _ = cleanupBackupFiles(archivePath) }() record.SizeBytes = sizeBytes + if err := s.saveRecord(ctx, record); err != nil { + return nil, fmt.Errorf("save initial record: %w", err) + } + if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil { + record.Status = "failed" + record.ErrorMsg = err.Error() + record.FinishedAt = time.Now().Format(time.RFC3339) + _ = s.saveRecord(ctx, record) + return record, err + } + record.Status = "completed" record.FinishedAt = time.Now().Format(time.RFC3339) if err := s.saveRecord(ctx, record); err != nil { @@ -656,78 +667,43 @@ func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, exp _ = s.saveRecord(context.Background(), record) } }() - s.executeBackup(record, objectStore) + s.executeBackup(record, objectStore, s3Cfg) }() return &result, nil } // executeBackup 后台执行备份(独立于 HTTP context) -func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore) { +func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore, s3Cfg *BackupS3Config) { ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) defer cancel() - // 阶段1: pg_dump + // 阶段1: pg_dump -> gzip 临时文件 record.Progress = "dumping" _ = s.saveRecord(ctx, record) - - dumpReader, err := s.dumper.Dump(ctx) + archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx) if err != nil { record.Status = "failed" - record.ErrorMsg = fmt.Sprintf("pg_dump failed: %v", err) + record.ErrorMsg = err.Error() record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) _ = s.saveRecord(context.Background(), record) return } + defer func() { _ = cleanupBackupFiles(archivePath) }() + record.SizeBytes = sizeBytes - // 阶段2: gzip + upload + // 阶段2: 单对象或分卷上传 record.Progress = "uploading" _ = s.saveRecord(ctx, record) - - pr, pw := io.Pipe() - gzipDone := make(chan error, 1) - go func() { - defer func() { - if r := recover(); r != nil { - pw.CloseWithError(fmt.Errorf("gzip goroutine panic: %v", r)) //nolint:errcheck - gzipDone <- fmt.Errorf("gzip goroutine panic: %v", r) - } - }() - gzWriter := gzip.NewWriter(pw) - var gzErr error - _, gzErr = io.Copy(gzWriter, dumpReader) - if closeErr := gzWriter.Close(); closeErr != nil && gzErr == nil { - gzErr = closeErr - } - if closeErr := dumpReader.Close(); closeErr != nil && gzErr == nil { - gzErr = closeErr - } - if gzErr != nil { - _ = pw.CloseWithError(gzErr) - } else { - _ = pw.Close() - } - gzipDone <- gzErr - }() - - contentType := "application/gzip" - sizeBytes, err := objectStore.Upload(ctx, record.S3Key, pr, contentType) - if err != nil { - _ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂 - gzErr := <-gzipDone // 安全等待 gzip goroutine 完成 + if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil { record.Status = "failed" - errMsg := fmt.Sprintf("S3 upload failed: %v", err) - if gzErr != nil { - errMsg = fmt.Sprintf("gzip/dump failed: %v", gzErr) - } - record.ErrorMsg = errMsg + record.ErrorMsg = err.Error() record.Progress = "" record.FinishedAt = time.Now().Format(time.RFC3339) _ = s.saveRecord(context.Background(), record) return } - <-gzipDone // 确保 gzip goroutine 已退出 record.SizeBytes = sizeBytes record.Status = "completed" @@ -738,6 +714,108 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb } } +func (s *BackupService) createCompressedBackupFile(ctx context.Context) (string, int64, error) { + dumpReader, err := s.dumper.Dump(ctx) + if err != nil { + return "", 0, fmt.Errorf("pg_dump: %w", err) + } + archive, err := os.CreateTemp("", "sub2api-backup-*.sql.gz") + if err != nil { + _ = dumpReader.Close() + return "", 0, fmt.Errorf("create backup archive: %w", err) + } + archivePath := archive.Name() + + gzWriter := gzip.NewWriter(archive) + _, copyErr := io.Copy(gzWriter, dumpReader) + if closeErr := gzWriter.Close(); copyErr == nil && closeErr != nil { + copyErr = closeErr + } + if closeErr := dumpReader.Close(); copyErr == nil && closeErr != nil { + copyErr = closeErr + } + if closeErr := archive.Close(); copyErr == nil && closeErr != nil { + copyErr = closeErr + } + if copyErr != nil { + _ = cleanupBackupFiles(archivePath) + return "", 0, fmt.Errorf("gzip/dump failed: %w", copyErr) + } + + info, err := os.Stat(archivePath) + if err != nil { + _ = cleanupBackupFiles(archivePath) + return "", 0, fmt.Errorf("stat backup archive: %w", err) + } + return archivePath, info.Size(), nil +} + +func (s *BackupService) uploadBackupArchive(ctx context.Context, record *BackupRecord, objectStore BackupObjectStore, cfg *BackupS3Config, archivePath string) error { + info, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("stat backup archive: %w", err) + } + partSize := s.partSizeBytes + if partSize <= 0 { + partSize = defaultBackupPartSizeBytes + } + if info.Size() <= partSize { + if _, err := objectStore.UploadFile(ctx, record.S3Key, archivePath, "application/gzip"); err != nil { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout) + cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record) + cleanupCancel() + return errors.Join(fmt.Errorf("backup upload: %w", err), cleanupErr) + } + record.Parts = nil + return nil + } + + localParts, err := splitBackupFile(archivePath, partSize) + if err != nil { + return fmt.Errorf("split backup archive: %w", err) + } + defer func() { + paths := make([]string, 0, len(localParts)) + for _, part := range localParts { + paths = append(paths, part.Path) + } + _ = cleanupBackupFiles(paths...) + }() + if cfg == nil { + return errors.New("backup S3 config is unavailable for split upload") + } + + record.S3Key = "" + record.Parts = make([]BackupPart, 0, len(localParts)) + partRoot := strings.TrimRight(s.buildS3Key(cfg, record.ID), "/") + for _, part := range localParts { + record.Parts = append(record.Parts, BackupPart{ + Index: part.Index, + S3Key: s.buildBackupPartKey(partRoot, part.Index), + SizeBytes: part.SizeBytes, + SHA256: part.SHA256, + }) + } + if err := s.saveRecord(ctx, record); err != nil { + return fmt.Errorf("save split backup plan: %w", err) + } + for i, part := range localParts { + if _, err := objectStore.UploadFile(ctx, record.Parts[i].S3Key, part.Path, "application/octet-stream"); err != nil { + // PUT 可能已经在对象存储端成功、但客户端因超时收到错误; + // 因此失败时清理整份分卷计划,而不只清理此前返回成功的卷。 + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout) + cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record) + cleanupCancel() + return errors.Join(fmt.Errorf("upload backup part %d: %w", part.Index, err), cleanupErr) + } + } + return nil +} + +func (s *BackupService) buildBackupPartKey(root string, index int) string { + return fmt.Sprintf("%s/payload.part-%06d", strings.TrimRight(root, "/"), index) +} + // RestoreBackup 从 S3 下载备份并流式恢复到数据库 func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error { s.opMu.Lock() @@ -770,7 +848,16 @@ func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) erro return fmt.Errorf("init object store: %w", err) } - // 从 S3 流式下载 + if len(record.Parts) > 0 { + archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts) + if err != nil { + return err + } + defer func() { _ = cleanupBackupFiles(archivePath) }() + return s.restoreArchive(ctx, archivePath) + } + + // 旧记录从 S3 流式下载 body, err := objectStore.Download(ctx, record.S3Key) if err != nil { return fmt.Errorf("S3 download failed: %w", err) @@ -866,6 +953,29 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute) defer cancel() + if len(record.Parts) > 0 { + archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts) + if err != nil { + record.RestoreStatus = "failed" + record.RestoreError = err.Error() + _ = s.saveRecord(context.Background(), record) + return + } + defer func() { _ = cleanupBackupFiles(archivePath) }() + if err := s.restoreArchive(ctx, archivePath); err != nil { + record.RestoreStatus = "failed" + record.RestoreError = fmt.Sprintf("pg restore: %v", err) + _ = s.saveRecord(context.Background(), record) + return + } + record.RestoreStatus = "completed" + record.RestoredAt = time.Now().Format(time.RFC3339) + if err := s.saveRecord(context.Background(), record); err != nil { + logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err) + } + return + } + body, err := objectStore.Download(ctx, record.S3Key) if err != nil { record.RestoreStatus = "failed" @@ -898,6 +1008,79 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO } } +func (s *BackupService) downloadBackupParts(ctx context.Context, objectStore BackupObjectStore, parts []BackupPart) (path string, err error) { + if len(parts) == 0 { + return "", errors.New("backup parts are empty") + } + ordered := append([]BackupPart(nil), parts...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i].Index < ordered[j].Index }) + for i, part := range ordered { + if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 { + return "", fmt.Errorf("invalid backup part metadata at index %d", i+1) + } + } + + archive, err := os.CreateTemp("", "sub2api-restore-*.sql.gz") + if err != nil { + return "", fmt.Errorf("create restore archive: %w", err) + } + path = archive.Name() + cleanup := func() { + _ = archive.Close() + _ = cleanupBackupFiles(path) + } + + for _, part := range ordered { + body, downloadErr := objectStore.Download(ctx, part.S3Key) + if downloadErr != nil { + cleanup() + return "", fmt.Errorf("download backup part %d: %w", part.Index, downloadErr) + } + hash := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(archive, hash), body) + closeErr := body.Close() + if copyErr != nil { + cleanup() + return "", fmt.Errorf("read backup part %d: %w", part.Index, copyErr) + } + if closeErr != nil { + cleanup() + return "", fmt.Errorf("close backup part %d: %w", part.Index, closeErr) + } + if written != part.SizeBytes { + cleanup() + return "", fmt.Errorf("backup part %d size mismatch: got %d, want %d", part.Index, written, part.SizeBytes) + } + if part.SHA256 != "" && !strings.EqualFold(part.SHA256, hex.EncodeToString(hash.Sum(nil))) { + cleanup() + return "", fmt.Errorf("backup part %d checksum mismatch", part.Index) + } + } + if err := archive.Close(); err != nil { + _ = cleanupBackupFiles(path) + return "", fmt.Errorf("close restore archive: %w", err) + } + return path, nil +} + +func (s *BackupService) restoreArchive(ctx context.Context, archivePath string) error { + archive, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open restore archive: %w", err) + } + defer func() { _ = archive.Close() }() + + gzReader, err := gzip.NewReader(archive) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer func() { _ = gzReader.Close() }() + if err := s.dumper.Restore(ctx, gzReader); err != nil { + return fmt.Errorf("pg restore: %w", err) + } + return nil +} + // ─── 备份记录管理 ─── func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) { @@ -946,45 +1129,67 @@ func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error if found == nil { return ErrBackupNotFound } + if found.Status == "running" { + // 后台上传仍可能依赖 Parts 计划;删除对象会让随后完成的记录引用失效卷。 + return ErrBackupInProgress + } - // 从 S3 删除 - if found.S3Key != "" && found.Status == "completed" { - s3Cfg, err := s.loadS3Config(ctx) - if err == nil && s3Cfg != nil && s3Cfg.IsConfigured() { - objectStore, err := s.getOrCreateStore(ctx, s3Cfg) - if err == nil { - _ = objectStore.Delete(ctx, found.S3Key) - } - } + // 从对象存储删除所有单文件或分卷对象。删除不完整时保留记录,便于重试。 + if err := s.deleteBackupObjects(ctx, found); err != nil { + return err } return s.saveRecordsLocked(ctx, remaining) } // GetBackupDownloadURL 获取备份文件预签名下载 URL -func (s *BackupService) GetBackupDownloadURL(ctx context.Context, backupID string) (string, error) { +func (s *BackupService) GetBackupDownloadURL(ctx context.Context, backupID string) (BackupDownloadResponse, error) { + var download BackupDownloadResponse record, err := s.GetBackupRecord(ctx, backupID) if err != nil { - return "", err + return download, err } if record.Status != "completed" { - return "", infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "backup is not completed") + return download, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "backup is not completed") } s3Cfg, err := s.loadS3Config(ctx) if err != nil { - return "", err + return download, err } objectStore, err := s.getOrCreateStore(ctx, s3Cfg) if err != nil { - return "", err + return download, err } + if len(record.Parts) > 0 { + parts := append([]BackupPart(nil), record.Parts...) + sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index }) + for i, part := range parts { + if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 { + return download, fmt.Errorf("invalid backup part metadata at index %d", i+1) + } + url, presignErr := objectStore.PresignURL(ctx, part.S3Key, 1*time.Hour) + if presignErr != nil { + return download, fmt.Errorf("presign backup part %d: %w", part.Index, presignErr) + } + download.Parts = append(download.Parts, BackupDownloadPart{ + Index: part.Index, + SizeBytes: part.SizeBytes, + URL: url, + }) + } + return download, nil + } + if record.S3Key == "" { + return download, errors.New("backup object key is empty") + } url, err := objectStore.PresignURL(ctx, record.S3Key, 1*time.Hour) if err != nil { - return "", fmt.Errorf("presign url: %w", err) + return download, fmt.Errorf("presign url: %w", err) } - return url, nil + download.URL = url + return download, nil } // ─── 内部方法 ─── @@ -1141,28 +1346,87 @@ func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupS } } - // 删除 S3 上的文件 + var cleanupErrs []error + deletedCount := 0 for _, r := range toDelete { - if r.S3Key != "" { - _ = s.deleteS3Object(ctx, r.S3Key) + if err := s.deleteBackupObjects(ctx, &r); err != nil { + // 对象删除失败时保留记录,避免丢失后续重试所需的 key。 + toKeep = append(toKeep, r) + cleanupErrs = append(cleanupErrs, fmt.Errorf("cleanup backup %s: %w", r.ID, err)) + continue } + deletedCount++ } if len(toDelete) > 0 { - logger.LegacyPrintf("service.backup", "[Backup] 自动清理了 %d 个过期备份", len(toDelete)) - return s.saveRecordsLocked(ctx, toKeep) + if err := s.saveRecordsLocked(ctx, toKeep); err != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("save backup records after cleanup: %w", err)) + } + if deletedCount > 0 { + logger.LegacyPrintf("service.backup", "[Backup] 自动清理了 %d 个过期备份", deletedCount) + } + return errors.Join(cleanupErrs...) } return nil } -func (s *BackupService) deleteS3Object(ctx context.Context, key string) error { +// backupObjectKeys 返回一条备份记录关联的全部对象 key。 +// 新记录使用 Parts,旧记录使用 S3Key;两者同时存在时也全部返回,便于清理异常残留对象。 +func backupObjectKeys(record *BackupRecord) []string { + if record == nil { + return nil + } + keys := make([]string, 0, len(record.Parts)+1) + seen := make(map[string]struct{}, len(record.Parts)+1) + appendKey := func(key string) { + if key == "" { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + keys = append(keys, key) + } + appendKey(record.S3Key) + parts := append([]BackupPart(nil), record.Parts...) + sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index }) + for _, part := range parts { + appendKey(part.S3Key) + } + return keys +} + +// deleteBackupObjects 尝试删除记录关联的所有对象,并聚合删除错误。 +func (s *BackupService) deleteBackupObjects(ctx context.Context, record *BackupRecord) error { + if len(backupObjectKeys(record)) == 0 { + return nil + } s3Cfg, err := s.loadS3Config(ctx) - if err != nil || s3Cfg == nil { + if err != nil { + return err + } + if s3Cfg == nil || !s3Cfg.IsConfigured() { + // 兼容没有配置对象存储的旧记录:记录仍可被删除。 return nil } objectStore, err := s.getOrCreateStore(ctx, s3Cfg) if err != nil { return err } - return objectStore.Delete(ctx, key) + return deleteBackupObjectKeys(ctx, objectStore, record) +} + +func deleteBackupObjectKeys(ctx context.Context, objectStore BackupObjectStore, record *BackupRecord) error { + keys := backupObjectKeys(record) + if len(keys) == 0 { + return nil + } + var errs []error + for _, key := range keys { + if deleteErr := objectStore.Delete(ctx, key); deleteErr != nil { + errs = append(errs, fmt.Errorf("delete backup object %q: %w", key, deleteErr)) + } + } + return errors.Join(errs...) } diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go index c39fa6b098..c599d719f4 100644 --- a/backend/internal/service/backup_service_test.go +++ b/backend/internal/service/backup_service_test.go @@ -4,10 +4,13 @@ package service import ( "bytes" + "compress/gzip" "context" + "crypto/sha256" "encoding/json" "fmt" "io" + "os" "strings" "sync" "testing" @@ -21,8 +24,10 @@ import ( // ─── Mocks ─── type mockSettingRepo struct { - mu sync.Mutex - data map[string]string + mu sync.Mutex + data map[string]string + getValueErr error + getValueCalls int } func newMockSettingRepo() *mockSettingRepo { @@ -42,6 +47,10 @@ func (m *mockSettingRepo) Get(_ context.Context, key string) (*Setting, error) { func (m *mockSettingRepo) GetValue(_ context.Context, key string) (string, error) { m.mu.Lock() defer m.mu.Unlock() + m.getValueCalls++ + if m.getValueErr != nil { + return "", m.getValueErr + } v, ok := m.data[key] if !ok { return "", nil @@ -159,12 +168,49 @@ func (d *blockingDumper) Restore(_ context.Context, data io.Reader) error { } type mockObjectStore struct { - objects map[string][]byte - mu sync.Mutex + objects map[string][]byte + mu sync.Mutex + failUploadFileAt int + uploadFileCalls int + deletedKeys []string + failDeleteKeys map[string]error +} + +type cancelingUploadFailureStore struct { + *mockObjectStore + cancel context.CancelFunc +} + +func (m *cancelingUploadFailureStore) UploadFile(_ context.Context, key string, filePath string, _ string) (int64, error) { + file, err := os.Open(filePath) + if err != nil { + return 0, err + } + data, readErr := io.ReadAll(file) + closeErr := file.Close() + if readErr != nil { + return 0, readErr + } + if closeErr != nil { + return 0, closeErr + } + + m.mu.Lock() + m.objects[key] = data + m.mu.Unlock() + m.cancel() + return 0, fmt.Errorf("injected upload failure after object landed") +} + +func (m *cancelingUploadFailureStore) Delete(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + return m.mockObjectStore.Delete(ctx, key) } func newMockObjectStore() *mockObjectStore { - return &mockObjectStore{objects: make(map[string][]byte)} + return &mockObjectStore{objects: make(map[string][]byte), failDeleteKeys: make(map[string]error)} } func (m *mockObjectStore) Upload(_ context.Context, key string, body io.Reader, _ string) (int64, error) { @@ -178,6 +224,23 @@ func (m *mockObjectStore) Upload(_ context.Context, key string, body io.Reader, return int64(len(data)), nil } +func (m *mockObjectStore) UploadFile(ctx context.Context, key string, filePath string, contentType string) (int64, error) { + m.mu.Lock() + m.uploadFileCalls++ + call := m.uploadFileCalls + failAt := m.failUploadFileAt + m.mu.Unlock() + if failAt > 0 && call == failAt { + return 0, fmt.Errorf("injected upload failure at call %d", call) + } + file, err := os.Open(filePath) + if err != nil { + return 0, err + } + defer func() { _ = file.Close() }() + return m.Upload(ctx, key, file, contentType) +} + func (m *mockObjectStore) Download(_ context.Context, key string) (io.ReadCloser, error) { m.mu.Lock() data, ok := m.objects[key] @@ -190,6 +253,11 @@ func (m *mockObjectStore) Download(_ context.Context, key string) (io.ReadCloser func (m *mockObjectStore) Delete(_ context.Context, key string) error { m.mu.Lock() + m.deletedKeys = append(m.deletedKeys, key) + if err, ok := m.failDeleteKeys[key]; ok { + m.mu.Unlock() + return err + } delete(m.objects, key) m.mu.Unlock() return nil @@ -405,6 +473,122 @@ func TestBackupService_CreateBackup_Streaming(t *testing.T) { store.mu.Unlock() } +func TestBackupService_CreateBackup_SplitsCompressedArchive(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + dumpContent := entropyBackupFixture(512) + dumper := &mockDumper{dumpData: dumpContent} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + svc.partSizeBytes = 32 + + record, err := svc.CreateBackup(context.Background(), "manual", 14) + require.NoError(t, err) + require.Equal(t, "completed", record.Status) + require.Greater(t, len(record.Parts), 1) + require.Empty(t, record.S3Key) + + var compressed bytes.Buffer + store.mu.Lock() + for _, part := range record.Parts { + data, ok := store.objects[part.S3Key] + require.True(t, ok) + require.LessOrEqual(t, len(data), 32) + compressed.Write(data) + } + store.mu.Unlock() + + gzReader, err := gzip.NewReader(bytes.NewReader(compressed.Bytes())) + require.NoError(t, err) + decompressed, err := io.ReadAll(gzReader) + require.NoError(t, err) + require.NoError(t, gzReader.Close()) + require.Equal(t, dumpContent, decompressed) +} + +func TestBackupService_StartBackup_SplitsCompressedArchive(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store) + svc.partSizeBytes = 32 + + record, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + svc.wg.Wait() + + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.Status) + require.Greater(t, len(final.Parts), 1) + require.Empty(t, final.S3Key) +} + +func TestBackupService_StartBackup_UploadFailureCleansUploadedParts(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + store.failUploadFileAt = 2 + svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store) + svc.partSizeBytes = 32 + + record, err := svc.StartBackup(context.Background(), "manual", 14) + require.NoError(t, err) + svc.wg.Wait() + + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "failed", final.Status) + require.NotEmpty(t, final.Parts) + store.mu.Lock() + deletedKeys := append([]string(nil), store.deletedKeys...) + store.mu.Unlock() + for _, part := range final.Parts { + require.Contains(t, deletedKeys, part.S3Key) + } +} + +func TestBackupService_UploadFailureCleanupUsesDetachedContext(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore()) + svc.partSizeBytes = 4 + + archive, err := os.CreateTemp("", "backup-upload-context-*.gz") + require.NoError(t, err) + archivePath := archive.Name() + defer func() { _ = os.Remove(archivePath) }() + _, err = archive.Write([]byte("0123456789")) + require.NoError(t, err) + require.NoError(t, archive.Close()) + + ctx, cancel := context.WithCancel(context.Background()) + store := &cancelingUploadFailureStore{ + mockObjectStore: newMockObjectStore(), + cancel: cancel, + } + record := &BackupRecord{ID: "cancel-cleanup", S3Key: "backups/cancel-cleanup.sql.gz"} + + err = svc.uploadBackupArchive(ctx, record, store, &BackupS3Config{Prefix: "backups"}, archivePath) + require.Error(t, err) + require.NotContains(t, err.Error(), "context canceled") + + store.mu.Lock() + defer store.mu.Unlock() + for _, part := range record.Parts { + require.Contains(t, store.deletedKeys, part.S3Key) + require.NotContains(t, store.objects, part.S3Key) + } +} + +func entropyBackupFixture(size int) []byte { + data := make([]byte, size) + for i := range data { + data[i] = byte((i*31 + 17) % 251) + } + return data +} + func TestBackupService_CreateBackup_DumpFailure(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -466,6 +650,123 @@ func TestBackupService_RestoreBackup_Streaming(t *testing.T) { require.Equal(t, dumpContent, string(dumper.restored)) } +func TestBackupService_RestoreBackup_SplitParts(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + dumpContent := entropyBackupFixture(512) + dumper := &mockDumper{} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + compressed := gzipBackupBytes(t, dumpContent) + parts := splitBackupBytes(compressed, 11) + recordParts := make([]BackupPart, 0, len(parts)) + for i, data := range parts { + key := fmt.Sprintf("backups/split-1/payload.part-%06d", i+1) + store.objects[key] = data + recordParts = append(recordParts, BackupPart{ + Index: i + 1, + S3Key: key, + SizeBytes: int64(len(data)), + SHA256: fmt.Sprintf("%x", sha256.Sum256(data)), + }) + } + record := &BackupRecord{ + ID: "split-1", + Status: "completed", + Parts: recordParts, + SizeBytes: int64(len(compressed)), + } + require.NoError(t, svc.saveRecord(context.Background(), record)) + + require.NoError(t, svc.RestoreBackup(context.Background(), record.ID)) + require.Equal(t, dumpContent, dumper.restored) +} + +func TestBackupService_RestoreBackup_SplitPartsMissingPartDoesNotRestore(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + dumpContent := entropyBackupFixture(256) + dumper := &mockDumper{} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + compressed := gzipBackupBytes(t, dumpContent) + parts := splitBackupBytes(compressed, 11) + recordParts := make([]BackupPart, 0, len(parts)) + for i, data := range parts { + key := fmt.Sprintf("backups/split-missing/payload.part-%06d", i+1) + store.objects[key] = data + recordParts = append(recordParts, BackupPart{ + Index: i + 1, + S3Key: key, + SizeBytes: int64(len(data)), + SHA256: fmt.Sprintf("%x", sha256.Sum256(data)), + }) + } + delete(store.objects, recordParts[1].S3Key) + record := &BackupRecord{ID: "split-missing", Status: "completed", Parts: recordParts} + require.NoError(t, svc.saveRecord(context.Background(), record)) + + require.Error(t, svc.RestoreBackup(context.Background(), record.ID)) + require.Empty(t, dumper.restored) +} + +func TestBackupService_DownloadBackupPartsRejectsMismatchedMetadata(t *testing.T) { + tests := []struct { + name string + part BackupPart + want string + }{ + { + name: "size", + part: BackupPart{Index: 1, S3Key: "backups/mismatch/size", SizeBytes: 4}, + want: "size mismatch", + }, + { + name: "checksum", + part: BackupPart{Index: 1, S3Key: "backups/mismatch/checksum", SizeBytes: 3, SHA256: "bad-checksum"}, + want: "checksum mismatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + store.objects[tt.part.S3Key] = []byte("abc") + svc := newTestBackupService(repo, &mockDumper{}, store) + + _, err := svc.downloadBackupParts(context.Background(), store, []BackupPart{tt.part}) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func gzipBackupBytes(t *testing.T, content []byte) []byte { + t.Helper() + var out bytes.Buffer + writer := gzip.NewWriter(&out) + _, err := writer.Write(content) + require.NoError(t, err) + require.NoError(t, writer.Close()) + return out.Bytes() +} + +func splitBackupBytes(data []byte, partSize int) [][]byte { + parts := make([][]byte, 0, (len(data)+partSize-1)/partSize) + for len(data) > 0 { + size := partSize + if len(data) < size { + size = len(data) + } + parts = append(parts, append([]byte(nil), data[:size]...)) + data = data[size:] + } + return parts +} + func TestBackupService_RestoreBackup_NotCompleted(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -512,6 +813,34 @@ func TestBackupService_DeleteBackup(t *testing.T) { require.ErrorIs(t, err, ErrBackupNotFound) } +func TestBackupService_DeleteBackup_RunningKeepsUploadObjects(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + parts := []BackupPart{ + {Index: 1, S3Key: "backups/running/payload.part-000001", SizeBytes: 3}, + {Index: 2, S3Key: "backups/running/payload.part-000002", SizeBytes: 3}, + } + for _, part := range parts { + store.objects[part.S3Key] = []byte("abc") + } + record := &BackupRecord{ID: "running-delete", Status: "running", Parts: parts} + require.NoError(t, svc.saveRecord(context.Background(), record)) + + err := svc.DeleteBackup(context.Background(), record.ID) + require.ErrorIs(t, err, ErrBackupInProgress) + store.mu.Lock() + require.Empty(t, store.deletedKeys) + for _, part := range parts { + require.Contains(t, store.objects, part.S3Key) + } + store.mu.Unlock() + got, getErr := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, getErr) + require.Equal(t, "running", got.Status) +} + func TestBackupService_GetDownloadURL(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -523,9 +852,103 @@ func TestBackupService_GetDownloadURL(t *testing.T) { record, err := svc.CreateBackup(context.Background(), "manual", 14) require.NoError(t, err) - url, err := svc.GetBackupDownloadURL(context.Background(), record.ID) + download, err := svc.GetBackupDownloadURL(context.Background(), record.ID) require.NoError(t, err) - require.Contains(t, url, "https://presigned.example.com/") + require.Contains(t, download.URL, "https://presigned.example.com/") +} + +func TestBackupService_DeleteBackup_SplitPartsFailureKeepsRecord(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + + parts := []BackupPart{ + {Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 3}, + {Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 3}, + {Index: 3, S3Key: "backups/split/payload.part-000003", SizeBytes: 3}, + } + for _, part := range parts { + store.objects[part.S3Key] = []byte("abc") + } + store.failDeleteKeys[parts[1].S3Key] = fmt.Errorf("delete failed") + record := &BackupRecord{ID: "split-delete", Status: "completed", Parts: parts} + require.NoError(t, svc.saveRecord(context.Background(), record)) + + err := svc.DeleteBackup(context.Background(), record.ID) + require.Error(t, err) + + store.mu.Lock() + deleted := append([]string(nil), store.deletedKeys...) + store.mu.Unlock() + for _, part := range parts { + require.Contains(t, deleted, part.S3Key) + } + got, getErr := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, getErr) + require.Equal(t, record.ID, got.ID) + store.mu.Lock() + require.Contains(t, store.objects, parts[1].S3Key) + store.mu.Unlock() +} + +func TestBackupService_GetDownloadURL_SplitParts(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + + parts := []BackupPart{ + {Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 7}, + {Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 5}, + } + record := &BackupRecord{ID: "split-download", Status: "completed", Parts: parts} + require.NoError(t, svc.saveRecord(context.Background(), record)) + + download, err := svc.GetBackupDownloadURL(context.Background(), record.ID) + require.NoError(t, err) + require.Empty(t, download.URL) + require.Len(t, download.Parts, 2) + require.Equal(t, 1, download.Parts[0].Index) + require.Equal(t, int64(5), download.Parts[0].SizeBytes) + require.Equal(t, "https://presigned.example.com/backups/split/payload.part-000001", download.Parts[0].URL) + require.Equal(t, 2, download.Parts[1].Index) +} + +func TestBackupService_CleanupOldBackups_SplitParts(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + now := time.Now() + parts := []BackupPart{ + {Index: 1, S3Key: "backups/old/payload.part-000001", SizeBytes: 3}, + {Index: 2, S3Key: "backups/old/payload.part-000002", SizeBytes: 3}, + } + for _, part := range parts { + store.objects[part.S3Key] = []byte("abc") + } + require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{ + ID: "new", + Status: "completed", + StartedAt: now.Format(time.RFC3339), + })) + require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{ + ID: "old", + Status: "completed", + StartedAt: now.Add(-2 * time.Hour).Format(time.RFC3339), + Parts: parts, + })) + + err := svc.cleanupOldBackups(context.Background(), &BackupScheduleConfig{RetainCount: 1}) + require.NoError(t, err) + _, err = svc.GetBackupRecord(context.Background(), "old") + require.ErrorIs(t, err, ErrBackupNotFound) + store.mu.Lock() + for _, part := range parts { + require.NotContains(t, store.objects, part.S3Key) + } + store.mu.Unlock() } func TestBackupService_ListBackups_Sorted(t *testing.T) { @@ -690,6 +1113,65 @@ func TestRecoverStaleRecords(t *testing.T) { require.Contains(t, r2.RestoreError, "server restart") } +func TestBackupService_RecoverStaleRecords_CleansBackupObjects(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + parts := []BackupPart{ + {Index: 1, S3Key: "backups/stale/payload.part-000001", SizeBytes: 3}, + {Index: 2, S3Key: "backups/stale/payload.part-000002", SizeBytes: 3}, + } + for _, part := range parts { + store.objects[part.S3Key] = []byte("abc") + } + require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-parts", + Status: "running", + Parts: parts, + StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339), + })) + + svc.recoverStaleRecords() + + record, err := svc.GetBackupRecord(context.Background(), "stale-parts") + require.NoError(t, err) + require.Equal(t, "failed", record.Status) + store.mu.Lock() + defer store.mu.Unlock() + for _, part := range parts { + require.Contains(t, store.deletedKeys, part.S3Key) + require.NotContains(t, store.objects, part.S3Key) + } +} + +func TestBackupService_RecoverStaleRecords_PreservesKeysWhenCleanupFails(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + store := newMockObjectStore() + svc := newTestBackupService(repo, &mockDumper{}, store) + part := BackupPart{Index: 1, S3Key: "backups/stale-failed/payload.part-000001", SizeBytes: 3} + store.objects[part.S3Key] = []byte("abc") + store.failDeleteKeys[part.S3Key] = fmt.Errorf("delete failed") + require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{ + ID: "stale-cleanup-failed", + Status: "running", + Parts: []BackupPart{part}, + StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339), + })) + + svc.recoverStaleRecords() + + record, err := svc.GetBackupRecord(context.Background(), "stale-cleanup-failed") + require.NoError(t, err) + require.Equal(t, "failed", record.Status) + require.Contains(t, record.ErrorMsg, "cleanup failed") + require.Equal(t, part.S3Key, record.Parts[0].S3Key) + store.mu.Lock() + defer store.mu.Unlock() + require.Contains(t, store.objects, part.S3Key) +} + func TestGracefulShutdown(t *testing.T) { repo := newMockSettingRepo() seedS3Config(t, repo) @@ -753,3 +1235,38 @@ func TestStartRestore_Async(t *testing.T) { require.NoError(t, err) require.Equal(t, "completed", final.RestoreStatus) } + +func TestBackupService_StartRestore_SplitParts(t *testing.T) { + repo := newMockSettingRepo() + seedS3Config(t, repo) + dumpContent := entropyBackupFixture(384) + dumper := &mockDumper{} + store := newMockObjectStore() + svc := newTestBackupService(repo, dumper, store) + + compressed := gzipBackupBytes(t, dumpContent) + parts := splitBackupBytes(compressed, 13) + recordParts := make([]BackupPart, 0, len(parts)) + for i, data := range parts { + key := fmt.Sprintf("backups/split-async/payload.part-%06d", i+1) + store.objects[key] = data + recordParts = append(recordParts, BackupPart{ + Index: i + 1, + S3Key: key, + SizeBytes: int64(len(data)), + SHA256: fmt.Sprintf("%x", sha256.Sum256(data)), + }) + } + record := &BackupRecord{ID: "split-async", Status: "completed", Parts: recordParts} + require.NoError(t, svc.saveRecord(context.Background(), record)) + + started, err := svc.StartRestore(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "running", started.RestoreStatus) + svc.wg.Wait() + + final, err := svc.GetBackupRecord(context.Background(), record.ID) + require.NoError(t, err) + require.Equal(t, "completed", final.RestoreStatus) + require.Equal(t, dumpContent, dumper.restored) +} diff --git a/backend/internal/service/billing_search_audio_cost_test.go b/backend/internal/service/billing_search_audio_cost_test.go new file mode 100644 index 0000000000..94d2e5c659 --- /dev/null +++ b/backend/internal/service/billing_search_audio_cost_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCalculateSearchCost(t *testing.T) { + t.Parallel() + s := &BillingService{} + require.Equal(t, 0.0, s.CalculateSearchCost(0, floatPtr(10), 1).ActualCost) + // nil price → default $10/1k: 5 calls = 0.05 + require.InDelta(t, 0.05, s.CalculateSearchCost(5, nil, 1).ActualCost, 1e-9) + // explicit 0 → free + require.Equal(t, 0.0, s.CalculateSearchCost(5, floatPtr(0), 1).ActualCost) + price := 10.0 + cost := s.CalculateSearchCost(100, &price, 1.5) + // 10 / 1000 * 100 * 1.5 = 1.5 + require.InDelta(t, 1.0, cost.TotalCost, 1e-9) + require.InDelta(t, 1.5, cost.ActualCost, 1e-9) +} + +func TestCalculateAudioCost(t *testing.T) { + t.Parallel() + s := &BillingService{} + rt, tts, stt := 0.10, 15.0, 0.50 + cfg := &audioPriceConfig{RealtimePerMin: &rt, TTSPerMChars: &tts, STTPerHour: &stt} + require.InDelta(t, 0.20, s.CalculateAudioCost("realtime", 2, cfg, 1).ActualCost, 1e-9) + require.InDelta(t, 1.5, s.CalculateAudioCost("tts", 0.1, cfg, 1).ActualCost, 1e-9) + require.InDelta(t, 0.25, s.CalculateAudioCost("stt", 0.5, cfg, 1).ActualCost, 1e-9) + require.Equal(t, 0.0, s.CalculateAudioCost("unknown", 1, cfg, 1).ActualCost) + // nil config → defaults (realtime $0.10/min, tts $15/M, stt $0.36/hr) + require.InDelta(t, 0.10, s.CalculateAudioCost("realtime", 1, nil, 1).ActualCost, 1e-9) + require.InDelta(t, 15.0, s.CalculateAudioCost("tts", 1, nil, 1).ActualCost, 1e-9) + require.InDelta(t, 0.36, s.CalculateAudioCost("stt", 1, nil, 1).ActualCost, 1e-9) + // explicit 0 → free + zero := 0.0 + require.Equal(t, 0.0, s.CalculateAudioCost("realtime", 1, &audioPriceConfig{RealtimePerMin: &zero}, 1).ActualCost) +} + +func floatPtr(v float64) *float64 { return &v } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 898138e799..96233b7ae9 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -316,27 +316,27 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{ - InputPricePerToken: 2.5e-6, - InputPricePerTokenPriority: 5e-6, - OutputPricePerToken: 15e-6, - OutputPricePerTokenPriority: 30e-6, - CacheCreationPricePerToken: 3.125e-6, - CacheCreationPricePerTokenPriority: 6.25e-6, - CacheReadPricePerToken: 0.25e-6, - CacheReadPricePerTokenPriority: 0.5e-6, + InputPricePerToken: 2e-6, + InputPricePerTokenPriority: 4e-6, + OutputPricePerToken: 12e-6, + OutputPricePerTokenPriority: 24e-6, + CacheCreationPricePerToken: 2.5e-6, + CacheCreationPricePerTokenPriority: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheReadPricePerTokenPriority: 0.4e-6, LongContextInputThreshold: openAIGPT54LongContextInputThreshold, LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{ - InputPricePerToken: 1e-6, - InputPricePerTokenPriority: 2e-6, - OutputPricePerToken: 6e-6, - OutputPricePerTokenPriority: 12e-6, - CacheCreationPricePerToken: 1.25e-6, - CacheCreationPricePerTokenPriority: 2.5e-6, - CacheReadPricePerToken: 0.1e-6, - CacheReadPricePerTokenPriority: 0.2e-6, + InputPricePerToken: 0.2e-6, + InputPricePerTokenPriority: 0.4e-6, + OutputPricePerToken: 1.2e-6, + OutputPricePerTokenPriority: 2.4e-6, + CacheCreationPricePerToken: 0.25e-6, + CacheCreationPricePerTokenPriority: 0.5e-6, + CacheReadPricePerToken: 0.02e-6, + CacheReadPricePerTokenPriority: 0.04e-6, LongContextInputThreshold: openAIGPT54LongContextInputThreshold, LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, @@ -403,6 +403,13 @@ func (s *BillingService) initFallbackPricing() { // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 // GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。 + // GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + s.fallbackPrices["glm-5.2"] = &ModelPricing{ + InputPricePerToken: 1.4e-6, // $1.40 per MTok + OutputPricePerToken: 4.4e-6, // $4.40 per MTok + CacheReadPricePerToken: 0.26e-6, + SupportsCacheBreakdown: false, + } s.fallbackPrices["glm-5.1"] = &ModelPricing{ InputPricePerToken: 1.4e-6, // $1.40 per MTok OutputPricePerToken: 4.4e-6, // $4.40 per MTok @@ -670,8 +677,12 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。 // 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。 - // 智谱 GLM(z.ai 公开 SKU:glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) + // 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) // 匹配顺序:先判别最高 tier,再依次降级。 + // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。 + if strings.Contains(modelLower, "glm-5.2") { + return s.fallbackPrices["glm-5.2"] + } if strings.Contains(modelLower, "glm-5.1") { return s.fallbackPrices["glm-5.1"] } @@ -808,6 +819,30 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return nil } +// HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。 +// +// 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会 +// 让任意含 "haiku"/"opus"/"claude" 的名字(哪怕是不存在的型号)落到 getFallbackPricing +// 的系列兜底价上,因此凡是模型名来自外部、且"能查到价"会直接影响计费金额的场景 +// (如按上游响应自报模型计费),都必须用本函数而不是 GetModelPricing 做准入判断。 +func (s *BillingService) HasIdentifiedTokenPricing(model string) bool { + if s == nil { + return false + } + model = strings.ToLower(strings.TrimSpace(model)) + if model == "" { + return false + } + if s.pricingService != nil { + // 仅有图片价的条目不能用于 token 计费,口径与 GetModelPricing 保持一致。 + if pricing := s.pricingService.GetIdentifiedModelPricing(model); pricing != nil && !pricing.TokenPricingAbsent { + return true + } + } + pricing, ok := s.fallbackPrices[model] + return ok && pricing != nil +} + // GetModelPricing 获取模型价格配置 func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { // 标准化模型名称(转小写) @@ -1404,6 +1439,9 @@ type VideoPriceConfig struct { Price480P *float64 // 480p 每秒价格(nil 表示使用默认值) Price720P *float64 // 720p 每秒价格(nil 表示使用默认值) Price1080P *float64 // 1080p 每秒价格(nil 表示使用默认值) + // ModelPrices is optional per-model-family override: family → resolution → USD/s. + // When set for a model, it wins over Price* flat columns for that model only. + ModelPrices map[string]map[string]float64 } const ( @@ -1423,6 +1461,15 @@ const ( // Codex alpha/search 网页搜索单次默认价:OpenAI 官方 web search 定价 $10/1000 次。 defaultWebSearchPricePerCall = 0.01 + + // Grok /v1/web_search 与 SearchCount 附加费:与 Codex 对齐 $10/1000 次(按 1k 计价字段存储)。 + defaultSearchPricePer1k = 10.0 + + // Grok Voice 默认价(分组列 NULL 时使用;显式配 0 表示免费)。 + // 保守运营占位,运维可通过 groups.audio_* 覆盖。 + defaultAudioRealtimePricePerMin = 0.10 + defaultAudioTTSPricePerMillionChars = 15.0 + defaultAudioSTTPricePerHour = 0.36 ) // CalculateWebSearchCost 计算 Codex alpha/search 网页搜索按次费用。 @@ -1450,6 +1497,80 @@ func (s *BillingService) CalculateWebSearchCost(callCount int, groupPrice *float } } +// CalculateSearchCost bills search/tool invocations (e.g. web_search) per 1k calls. +// groupPricePer1k: nil → defaultSearchPricePer1k; explicit 0 → free; >0 → that rate. +func (s *BillingService) CalculateSearchCost(numCalls int, groupPricePer1k *float64, rateMultiplier float64) *CostBreakdown { + if numCalls <= 0 { + return &CostBreakdown{} + } + pricePer1k := defaultSearchPricePer1k + if groupPricePer1k != nil { + if *groupPricePer1k < 0 { + return &CostBreakdown{} + } + pricePer1k = *groupPricePer1k + } + if pricePer1k == 0 { + return &CostBreakdown{} + } + if rateMultiplier < 0 { + rateMultiplier = 0 + } + unit := pricePer1k / 1000.0 + total := unit * float64(numCalls) + return &CostBreakdown{ + TotalCost: total, + ActualCost: total * rateMultiplier, + BillingMode: string(BillingModePerRequest), + } +} + +type audioPriceConfig struct { + RealtimePerMin *float64 + TTSPerMChars *float64 + STTPerHour *float64 +} + +// CalculateAudioCost supports realtime (per min), tts (per M chars), stt (per hr). +// Missing group prices use defaults; explicit 0 means free for that mode. +func (s *BillingService) CalculateAudioCost(mode string, durationOrUnits float64, groupConfig *audioPriceConfig, rateMultiplier float64) *CostBreakdown { + if durationOrUnits <= 0 { + return &CostBreakdown{} + } + var unitPrice float64 + switch strings.ToLower(mode) { + case "realtime": + unitPrice = defaultAudioRealtimePricePerMin + if groupConfig != nil && groupConfig.RealtimePerMin != nil { + unitPrice = *groupConfig.RealtimePerMin + } + case "tts": + unitPrice = defaultAudioTTSPricePerMillionChars + if groupConfig != nil && groupConfig.TTSPerMChars != nil { + unitPrice = *groupConfig.TTSPerMChars + } + case "stt": + unitPrice = defaultAudioSTTPricePerHour + if groupConfig != nil && groupConfig.STTPerHour != nil { + unitPrice = *groupConfig.STTPerHour + } + default: + return &CostBreakdown{} + } + if unitPrice <= 0 { + return &CostBreakdown{} + } + if rateMultiplier < 0 { + rateMultiplier = 0 + } + total := unitPrice * durationOrUnits + return &CostBreakdown{ + TotalCost: total, + ActualCost: total * rateMultiplier, + BillingMode: string(BillingModePerRequest), + } +} + // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" @@ -1535,8 +1656,12 @@ func (s *BillingService) getImageUnitPrice(model string, imageSize string, group } func (s *BillingService) getVideoUnitPrice(model string, resolution string, groupConfig *VideoPriceConfig) float64 { + // Order: (a) per-model map (b) flat group video_price_* (c) model-aware code defaults. if groupConfig != nil { - switch resolution { + if price := LookupVideoModelPrice(groupConfig.ModelPrices, model, resolution); price != nil { + return *price + } + switch NormalizeVideoBillingResolutionOrDefault(resolution) { case VideoBillingResolution480P: if groupConfig.Price480P != nil { return *groupConfig.Price480P diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 5320df9f19..8f322b54e9 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -158,17 +158,19 @@ func TestGetModelPricing_FallbackWarnPerModelNotGlobal(t *testing.T) { require.Equal(t, 0, strings.Count(out, "model: GLM-5.2"), out) // 大写经 ToLower 归一,不应单独成行 } -// 回归:glm-5.2 仍解析到 glm-5 兜底价(计费金额不变,防止日志改动掩盖未来计费回归)。 -func TestGetModelPricing_GLM52FallsBackToGLM5Price(t *testing.T) { +// 回归:glm-5.2 必须命中自己的兜底价,不能被 strings.Contains("glm-5") 抢成 glm-5 价。 +// 历史 bug:兜底表缺 glm-5.2 条目,使用记录按 $1.00/$3.20 计费,比官方 $1.40/$4.40 少收约 27%。 +func TestGetModelPricing_GLM52UsesOwnPrice(t *testing.T) { svc := newTestBillingService() got, err := svc.GetModelPricing("glm-5.2") require.NoError(t, err) require.NotNil(t, got) - // glm-5 base:Input 1e-6 / Output 3.2e-6(见 TestGetFallbackPricing_FamilyMatching)。 - require.InDelta(t, 1e-6, got.InputPricePerToken, 1e-12) - require.InDelta(t, 3.2e-6, got.OutputPricePerToken, 1e-12) + // 官方 z.ai 口径:与 glm-5.1 同价(见 TestGetFallbackPricing_FamilyMatching)。 + require.InDelta(t, 1.4e-6, got.InputPricePerToken, 1e-12) + require.InDelta(t, 4.4e-6, got.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.26e-6, got.CacheReadPricePerToken, 1e-12) } func TestGetModelPricing_UnknownClaudeModelFallsBackToSonnet(t *testing.T) { @@ -489,6 +491,13 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 智谱 GLM(z.ai USD 口径)---- + { + name: "glm 5.2 flagship", + model: "glm-5.2", + expectedInput: 1.4e-6, + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 5.1 flagship", model: "glm-5.1", @@ -572,7 +581,7 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedInput: 0.1e-6, expectedOutput: floatPtr(0.1e-6), }, - // 关键:5.1 必须先于 5 匹配(避免被 glm-5 抢走) + // 关键:5.1 / 5.2 必须先于 5 匹配(避免被 glm-5 抢走) { name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)", model: "glm-5.1", @@ -580,6 +589,13 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedOutput: floatPtr(4.4e-6), expectedCacheRead: floatPtr(0.26e-6), }, + { + name: "glm 5.2 vs glm 5 ordering (verbatim 5.2)", + model: "glm-5.2", + expectedInput: 1.4e-6, // = glm-5.2 价格(不是 glm-5 的 1e-6) + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 4.5-air vs glm 4.5 ordering", model: "glm-4.5-air", diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 4a3bdf304d..5ed834eeca 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -39,6 +39,10 @@ const ( BillingModelSourceRequested = "requested" BillingModelSourceUpstream = "upstream" BillingModelSourceChannelMapped = "channel_mapped" + // BillingModelSourceResponse bills by a trusted model declaration observed + // in the successful upstream response. It is deliberately distinct from + // "upstream", which means the model sent to the provider. + BillingModelSourceResponse = "response_model" ) // Channel 渠道实体 @@ -47,7 +51,7 @@ type Channel struct { Name string Description string Status string - BillingModelSource string // "requested", "upstream", or "channel_mapped" + BillingModelSource string // "requested", "upstream", "channel_mapped", or "response_model" RestrictModels bool // 是否限制模型(仅允许定价列表中的模型) Features string // 渠道特性描述(JSON 数组),用于支付页面展示 FeaturesConfig map[string]any // 渠道功能配置(如 web search emulation) diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 2ee8eabde8..6a41add298 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -157,3 +157,18 @@ var ( "CHANNEL_MONITOR_KEY_DECRYPT_FAILED", "api key decryption failed; please re-edit the monitor with a fresh key", ) ) + +var ( + ErrChannelMonitorDisabled = infraerrors.Forbidden( + "CHANNEL_MONITOR_DISABLED", + "channel monitor feature is disabled", + ) + ErrChannelMonitorActiveProbesRetired = infraerrors.Forbidden( + "CHANNEL_MONITOR_ACTIVE_PROBES_RETIRED", + "channel monitor active probes are retired in v2 mode", + ) + ErrChannelMonitorModeMismatch = infraerrors.Forbidden( + "CHANNEL_MONITOR_MODE_MISMATCH", + "channel monitor mode does not allow this operation", + ) +) diff --git a/backend/internal/service/channel_monitor_probe_retirement_test.go b/backend/internal/service/channel_monitor_probe_retirement_test.go new file mode 100644 index 0000000000..97bc8bbb47 --- /dev/null +++ b/backend/internal/service/channel_monitor_probe_retirement_test.go @@ -0,0 +1,63 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type channelMonitorRuntimeStub struct { + rt ChannelMonitorRuntime +} + +func (s channelMonitorRuntimeStub) GetChannelMonitorRuntime(context.Context) ChannelMonitorRuntime { + return s.rt +} + +func TestRunCheck_ModeV2NeverProbes(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ + Enabled: true, + Mode: ChannelMonitorModeV2, + }}) + + results, err := svc.RunCheck(context.Background(), 1) + require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired) + require.Nil(t, results) +} + +func TestRunCheck_DisabledReturnsDisabled(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{ + Enabled: false, + Mode: ChannelMonitorModeV1, + }}) + + _, err := svc.RunCheck(context.Background(), 1) + require.ErrorIs(t, err, ErrChannelMonitorDisabled) +} + +func TestRunCheck_NilRuntimeReaderFailsClosedAsV2(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + // No SetRuntimeReader → probeRuntime defaults to mode=v2 (retired). + _, err := svc.RunCheck(context.Background(), 1) + require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired) +} + +func TestNormalizeChannelMonitorMode(t *testing.T) { + require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("")) + require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("v1")) + require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode("v2")) + require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("invalid")) + require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode(" V1 ")) +} + +func TestChannelMonitorRuntimeActiveProbesAllowed(t *testing.T) { + require.False(t, (ChannelMonitorRuntime{Enabled: false, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed()) + require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed()) + require.False(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).ActiveProbesAllowed()) + require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).PassiveAggregationAllowed()) +} diff --git a/backend/internal/service/channel_monitor_runner.go b/backend/internal/service/channel_monitor_runner.go index dbf03c2395..9da063c17d 100644 --- a/backend/internal/service/channel_monitor_runner.go +++ b/backend/internal/service/channel_monitor_runner.go @@ -257,8 +257,11 @@ func (r *ChannelMonitorRunner) runScheduled(ctx context.Context, task *scheduled // fire 提交一次检测到 worker 池。功能开关关闭时跳过本次(不取消任务, // 重新启用时立即恢复);池满或重复在飞时也跳过。 func (r *ChannelMonitorRunner) fire(ctx context.Context, task *scheduledMonitor) { - if r.settingService != nil && !r.settingService.GetChannelMonitorRuntime(ctx).Enabled { - return + if r.settingService != nil { + rt := r.settingService.GetChannelMonitorRuntime(ctx) + if !rt.ActiveProbesAllowed() { + return + } } if !r.tryAcquireInFlight(task.id) { slog.Debug("channel_monitor: skip already in-flight", diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 9cc793b43b..248b72133d 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -61,10 +61,19 @@ type ChannelMonitorRepository interface { UpdateAggregationWatermark(ctx context.Context, date time.Time) error } +// channelMonitorRuntimeReader is the optional settings view used to gate V1 +// active probes by channel_monitor_enabled + channel_monitor_mode. +type channelMonitorRuntimeReader interface { + GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime +} + // ChannelMonitorService 渠道监控管理服务。 type ChannelMonitorService struct { repo ChannelMonitorRepository encryptor SecretEncryptor + // settings is optional; when nil, RunCheck fails closed for active probes + // (mode defaults to v2 / retired) so tests without settings never hit upstream. + settings channelMonitorRuntimeReader // scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。 // 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。 scheduler MonitorScheduler @@ -83,6 +92,22 @@ func NewChannelMonitorService(repo ChannelMonitorRepository, encryptor SecretEnc return &ChannelMonitorService{repo: repo, encryptor: encryptor} } +// SetRuntimeReader injects the settings reader used to gate active probes. +// Optional: when unset, active probes are treated as mode=v2 (retired). +func (s *ChannelMonitorService) SetRuntimeReader(r channelMonitorRuntimeReader) { + if s == nil { + return + } + s.settings = r +} + +func (s *ChannelMonitorService) probeRuntime(ctx context.Context) ChannelMonitorRuntime { + if s == nil || s.settings == nil { + return ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2} + } + return s.settings.GetChannelMonitorRuntime(ctx) +} + // ---------- CRUD ---------- // List 列表查询(支持 provider/enabled/search 过滤 + 分页)。 @@ -427,7 +452,16 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model // RunCheck 同步触发对一个监控的检测:并发跑 primary + extra 模型, // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。 +// 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测; +// mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。 func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) { + rt := s.probeRuntime(ctx) + if !rt.Enabled { + return nil, ErrChannelMonitorDisabled + } + if !rt.ActiveProbesAllowed() { + return nil, ErrChannelMonitorActiveProbesRetired + } m, err := s.Get(ctx, id) // 已解密 APIKey if err != nil { return nil, err diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go new file mode 100644 index 0000000000..8d6198918b --- /dev/null +++ b/backend/internal/service/channel_monitor_v2.go @@ -0,0 +1,1079 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +const ( + ChannelMonitorV2OtherModel = "__other__" + ChannelMonitorV2TaxonomyVersion = 1 +) + +var ( + ErrChannelMonitorV2InvalidRange = errors.New("invalid channel monitor v2 range") + ErrChannelMonitorV2InvalidGroupBy = errors.New("invalid channel monitor v2 group_by") + ErrChannelMonitorV2InvalidConfig = errors.New("invalid channel monitor v2 config") + ErrChannelMonitorV2ConfigConflict = errors.New("channel monitor v2 config was modified") +) + +type ChannelMonitorV2GroupBy string + +const ( + ChannelMonitorV2GroupByPlatform ChannelMonitorV2GroupBy = "platform" + ChannelMonitorV2GroupByPlatformGroup ChannelMonitorV2GroupBy = "platform_group" + ChannelMonitorV2GroupByPlatformModel ChannelMonitorV2GroupBy = "platform_model" + ChannelMonitorV2GroupByPlatformGroupModel ChannelMonitorV2GroupBy = "platform_group_model" +) + +type ChannelMonitorV2PlatformConfig struct { + Platform string `json:"platform"` + Enabled bool `json:"enabled"` + Models []string `json:"models"` +} + +type ChannelMonitorV2Config struct { + Version int `json:"version"` + Enabled bool `json:"enabled"` + RefreshIntervalSeconds int `json:"refresh_interval_seconds"` + Platforms []ChannelMonitorV2PlatformConfig `json:"platforms"` + GroupIDs []int64 `json:"group_ids"` + HealthThresholds ChannelMonitorV2HealthThresholds `json:"health_thresholds"` + // IgnoredErrorCategories are excluded from error_rate / health scoring. + // They still appear in the error breakdown with ignored=true (greyed in UI). + // Unknown categories always roll into "other" via the taxonomy classifier. + IgnoredErrorCategories []string `json:"ignored_error_categories"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy *int64 `json:"updated_by,omitempty"` +} + +// ChannelMonitorV2ErrorCategories is the ordered, versioned taxonomy used by the +// classifier and admin settings UI. Unmatched errors become "other". +var ChannelMonitorV2ErrorCategories = []string{ + "content_policy", + "authentication", + "context_limit", + "invalid_request", + "model_unsupported", + "group_access", + "quota_or_balance", + "account_pool_unavailable", + "rate_or_capacity", + "timeout", + "transport_or_stream", + "upstream_forbidden", + "not_found", + "client_cancelled", + "upstream_5xx", + "internal", + "other", +} + +type ChannelMonitorV2Filter struct { + Range string + Platforms []string + GroupIDs []int64 + Models []string + Start time.Time + End time.Time + Bucket time.Duration +} + +type ChannelMonitorV2Metric struct { + SuccessRequests int64 `json:"success_requests"` + ErrorRequests int64 `json:"error_requests"` + RequestCount int64 `json:"request_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + TokenCount int64 `json:"token_count"` + RPM float64 `json:"rpm"` + TPM float64 `json:"tpm"` + ErrorRate float64 `json:"error_rate"` + SuccessRate float64 `json:"success_rate"` + CacheRate float64 `json:"cache_rate"` + CacheRateNumerator int64 `json:"cache_rate_numerator"` + CacheRateDenominator int64 `json:"cache_rate_denominator"` + TTFT ChannelMonitorV2Latency `json:"ttft"` + Duration ChannelMonitorV2Latency `json:"duration"` + UpstreamAffectedRequests *int64 `json:"upstream_affected_requests,omitempty"` + UpstreamAttemptCount *int64 `json:"upstream_attempt_count,omitempty"` +} + +type ChannelMonitorV2Latency struct { + SampleCount int64 `json:"sample_count"` + P50Ms *int64 `json:"p50_ms"` + P90Ms *int64 `json:"p90_ms"` + P95Ms *int64 `json:"p95_ms"` + AvgMs *float64 `json:"avg_ms"` +} + +type ChannelMonitorV2Health struct { + Overall string `json:"overall"` + ErrorRate string `json:"error_rate"` + TTFT string `json:"ttft"` + Cache string `json:"cache"` + // Score is 0–100 when samples are sufficient; omitted/null when unknown. + // Overall blends error-rate, TTFT p50, and cache rate (weights in Thresholds). + Score *float64 `json:"score,omitempty"` + ErrorRateScore *float64 `json:"error_rate_score,omitempty"` + TTFTScore *float64 `json:"ttft_score,omitempty"` + CacheScore *float64 `json:"cache_score,omitempty"` + MinimumSample int64 `json:"minimum_sample"` + Thresholds ChannelMonitorV2HealthThresholds `json:"thresholds"` +} + +type ChannelMonitorV2HealthThresholds struct { + // MinimumSample is required before scoring request/latency/cache signals. + MinimumSample int64 `json:"minimum_sample"` + // WarningErrorRate / CriticalErrorRate map to discrete bands for legacy UI. + WarningErrorRate float64 `json:"warning_error_rate"` + CriticalErrorRate float64 `json:"critical_error_rate"` + // TargetTTFTMs is the primary TTFT p50 budget (ms). At or below this is full TTFT score. + TargetTTFTMs int64 `json:"target_ttft_ms"` + WarningTTFTMs int64 `json:"warning_ttft_ms"` + CriticalTTFTMs int64 `json:"critical_ttft_ms"` + // WarningCacheRate / CriticalCacheRate: cache rate below these → warning/critical bands. + // Higher cache rate is better; defaults 20% warning / 5% critical. + WarningCacheRate float64 `json:"warning_cache_rate"` + CriticalCacheRate float64 `json:"critical_cache_rate"` + // ErrorWeight + TTFTWeight + CacheWeight should sum to 1.0. + ErrorWeight float64 `json:"error_weight"` + TTFTWeight float64 `json:"ttft_weight"` + CacheWeight float64 `json:"cache_weight"` +} + +type ChannelMonitorV2Coverage struct { + RequestedStart time.Time `json:"requested_start"` + // RequestedEnd is the exclusive upper bound of the UI-selected window + // (filter.End). Charts/matrices should plot [RequestedStart, RequestedEnd) + // even when CoverageStart is later (partial backfill). + RequestedEnd time.Time `json:"requested_end"` + CoverageStart time.Time `json:"coverage_start"` + DataThrough time.Time `json:"data_through"` + ComputedAt time.Time `json:"computed_at"` + AggregationLagSeconds int64 `json:"aggregation_lag_seconds"` + // CoverageComplete is true when aggregated history reaches the requested + // window start (backfill / product coverage for this range is done). + // Trailing lag — data_through behind filter.End because UI ends align to + // the next bucket and may sit slightly in the future — does not make this + // false once history is covered. + CoverageComplete bool `json:"coverage_complete"` + BucketSeconds int `json:"bucket_seconds"` + // Bootstrap is set while the first-upgrade historical backfill toward the + // product windows (90m / 24h / 7d / 30d) is still running. Omitted or + // inactive once the 30d UI range is fully covered; longer retention (90d) + // may continue silently without a progress banner. + Bootstrap *ChannelMonitorV2Bootstrap `json:"bootstrap,omitempty"` +} + +// ChannelMonitorV2Bootstrap reports first-upgrade aggregation progress for the UI. +// Progress is measured against the longest product window (30d), not full 90d retention. +type ChannelMonitorV2Bootstrap struct { + // Active is true while historical backfill has not yet covered the 30d product window. + Active bool `json:"active"` + // ProgressPercent is 0–100 of history covered from now back toward TargetStart. + ProgressPercent int `json:"progress_percent"` + // CoveredFrom is the earliest minute already recomputed (backfill cursor). + CoveredFrom time.Time `json:"covered_from,omitempty"` + // TargetStart is now−30d (product bootstrap goal for 90m/24h/7d/30d). + TargetStart time.Time `json:"target_start,omitempty"` +} + +// ChannelMonitorV2AggregationWatermark is the durable aggregator cursor state. +type ChannelMonitorV2AggregationWatermark struct { + UsageCoverageStart time.Time + ErrorCoverageStart time.Time + DataThrough time.Time + LastSuccessfulAt time.Time + // BackfillCursor is the earliest start already recomputed; zero when never set. + BackfillCursor time.Time + // HasData is true once any successful recompute wrote data_through. + HasData bool +} + +type ChannelMonitorV2TrendPoint struct { + BucketStart time.Time `json:"bucket_start"` + Metrics ChannelMonitorV2Metric `json:"metrics"` + Health ChannelMonitorV2Health `json:"health"` +} + +type ChannelMonitorV2Snapshot struct { + Config ChannelMonitorV2Config `json:"config"` + Coverage ChannelMonitorV2Coverage `json:"coverage"` + Metrics ChannelMonitorV2Metric `json:"metrics"` + Health ChannelMonitorV2Health `json:"health"` + Trend []ChannelMonitorV2TrendPoint `json:"trend"` +} + +type ChannelMonitorV2Dimension struct { + Value string `json:"value"` + Label string `json:"label"` + Platform string `json:"platform,omitempty"` + RequestCount int64 `json:"request_count"` +} + +type ChannelMonitorV2GroupDimension struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform,omitempty"` + RequestCount int64 `json:"request_count"` +} + +type ChannelMonitorV2Dimensions struct { + Platforms []ChannelMonitorV2Dimension `json:"platforms"` + Groups []ChannelMonitorV2GroupDimension `json:"groups"` + Models []ChannelMonitorV2Dimension `json:"models"` +} + +type ChannelMonitorV2ModelRow struct { + Platform string `json:"platform"` + Model string `json:"model"` + Metrics ChannelMonitorV2Metric `json:"metrics"` + Health ChannelMonitorV2Health `json:"health"` +} + +type ChannelMonitorV2MatrixRow struct { + Platform string `json:"platform"` + GroupID *int64 `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + Model string `json:"model,omitempty"` + Metrics ChannelMonitorV2Metric `json:"metrics"` + Health ChannelMonitorV2Health `json:"health"` + Buckets []ChannelMonitorV2TrendPoint `json:"buckets"` +} + +type ChannelMonitorV2Matrix struct { + GroupBy ChannelMonitorV2GroupBy `json:"group_by"` + Coverage ChannelMonitorV2Coverage `json:"coverage"` + Items []ChannelMonitorV2MatrixRow `json:"items"` +} + +type ChannelMonitorV2ErrorRow struct { + Category string `json:"category"` + Count int64 `json:"count"` + Rate float64 `json:"rate"` + Details []ChannelMonitorV2ErrorDetail `json:"details,omitempty"` + // Ignored is true when this category is excluded from error_rate scoring + // via config.ignored_error_categories. Still shown in breakdown (UI greys it). + Ignored bool `json:"ignored"` +} + +type ChannelMonitorV2ErrorDetail struct { + Platform string `json:"platform,omitempty"` + Model string `json:"model,omitempty"` + ErrorType string `json:"error_type,omitempty"` + StatusCode int `json:"status_code,omitempty"` + UpstreamStatusCode int `json:"upstream_status_code,omitempty"` + Message string `json:"message,omitempty"` + Count int64 `json:"count"` +} + +type ChannelMonitorV2UserRow struct { + UserID *int64 `json:"user_id,omitempty"` + Rank int `json:"rank"` + Email string `json:"email,omitempty"` + Username string `json:"username,omitempty"` + DisplayLabel string `json:"display_label"` + IsSelf bool `json:"is_self"` + CanDrilldown bool `json:"can_drilldown"` + Metrics ChannelMonitorV2Metric `json:"metrics"` +} + +type ChannelMonitorV2List[T any] struct { + Coverage ChannelMonitorV2Coverage `json:"coverage"` + Items []T `json:"items"` +} + +type ChannelMonitorV2Repository interface { + GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) + UpdateConfig(ctx context.Context, config ChannelMonitorV2Config, expectedVersion int) (*ChannelMonitorV2Config, error) + GetDimensions(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error) + GetSnapshot(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2Snapshot, error) + GetModels(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) + GetMatrix(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, includeAdmin bool) (*ChannelMonitorV2Matrix, error) + // GetErrors loads category rates. When includeAdmin is false, implementations + // must omit error Details (no ops_error_logs sample scan) for privacy. + GetErrors(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) + GetUsers(ctx context.Context, filter ChannelMonitorV2Filter, config ChannelMonitorV2Config, includeAdmin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) + // GetAggregationWatermark loads durable backfill / coverage cursors for the + // passive aggregator (and bootstrap progress). Missing row → zero value, nil error. + GetAggregationWatermark(ctx context.Context) (*ChannelMonitorV2AggregationWatermark, error) + RecomputeRange(ctx context.Context, start, end time.Time) error +} + +// ChannelMonitorV2BootstrapProductWindow is the longest UI range that must be +// filled on first upgrade before the bootstrap banner disappears (30d). +const ChannelMonitorV2BootstrapProductWindow = 30 * 24 * time.Hour + +// ChannelMonitorV2BootstrapProgress builds the optional UI progress payload. +// now and coveredFrom should be UTC minute-truncated when possible. +// When history is complete for the 30d product window, returns nil (no banner). +func ChannelMonitorV2BootstrapProgress(now, coveredFrom time.Time, hasData bool) *ChannelMonitorV2Bootstrap { + now = now.UTC().Truncate(time.Minute) + targetStart := now.Add(-ChannelMonitorV2BootstrapProductWindow) + if !hasData { + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: 0, + TargetStart: targetStart, + } + } + if coveredFrom.IsZero() { + // Have data_through but no cursor yet — treat as barely started. + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: 0, + TargetStart: targetStart, + } + } + coveredFrom = coveredFrom.UTC().Truncate(time.Minute) + if !coveredFrom.After(targetStart) { + // 30d product windows fully covered; hide progress (90d retention may continue). + return nil + } + total := now.Sub(targetStart).Seconds() + if total <= 0 { + return nil + } + done := now.Sub(coveredFrom).Seconds() + if done < 0 { + done = 0 + } + if done > total { + done = total + } + // Round to nearest percent; any non-zero history shows at least 1% so the + // bar moves after the first 2h bootstrap tick (2h/30d ≈ 0.3%). + pct := int((done/total)*100 + 0.5) + if done > 0 && pct < 1 { + pct = 1 + } + if pct > 100 { + pct = 100 + } + if pct >= 100 { + // Cursor still slightly after target due to rounding — keep active until + // coveredFrom <= targetStart so the banner does not flash 100% then vanish. + pct = 99 + } + return &ChannelMonitorV2Bootstrap{ + Active: true, + ProgressPercent: pct, + CoveredFrom: coveredFrom, + TargetStart: targetStart, + } +} + +type ChannelMonitorV2Service struct { + repo ChannelMonitorV2Repository + settings channelMonitorRuntimeReader + now func() time.Time +} + +func NewChannelMonitorV2Service(repo ChannelMonitorV2Repository) *ChannelMonitorV2Service { + return &ChannelMonitorV2Service{repo: repo, now: func() time.Time { return time.Now().UTC() }} +} + +// SetRuntimeReader wires optional settings for privacy flags (hide throughput). +func (s *ChannelMonitorV2Service) SetRuntimeReader(r channelMonitorRuntimeReader) { + if s == nil { + return + } + s.settings = r +} + +func (s *ChannelMonitorV2Service) hideThroughputForViewer(ctx context.Context, admin bool) bool { + if admin { + return false + } + // Privacy is fail-closed: an absent or unavailable settings reader must not + // expose fleet-scale rates to ordinary users. + if s == nil || s.settings == nil { + return true + } + return s.settings.GetChannelMonitorRuntime(ctx).HideThroughput +} + +func (s *ChannelMonitorV2Service) GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) { + return s.repo.GetConfig(ctx) +} + +func (s *ChannelMonitorV2Service) getEnabledConfig(ctx context.Context) (*ChannelMonitorV2Config, error) { + cfg, err := s.repo.GetConfig(ctx) + if err != nil { + return nil, err + } + if cfg == nil || !cfg.Enabled { + return nil, ErrChannelMonitorDisabled + } + return cfg, nil +} + +func (s *ChannelMonitorV2Service) UpdateConfig(ctx context.Context, cfg ChannelMonitorV2Config, expectedVersion int, actorID int64) (*ChannelMonitorV2Config, error) { + if err := normalizeChannelMonitorV2Config(&cfg); err != nil { + return nil, err + } + cfg.UpdatedBy = &actorID + return s.repo.UpdateConfig(ctx, cfg, expectedVersion) +} + +func (s *ChannelMonitorV2Service) ParseFilter(rangeValue string, platforms, models []string, groupIDs []int64) (ChannelMonitorV2Filter, error) { + now := s.now().UTC() + var window, bucket time.Duration + switch strings.TrimSpace(rangeValue) { + case "", "90m": + rangeValue, window, bucket = "90m", 90*time.Minute, 5*time.Minute + case "24h": + window, bucket = 24*time.Hour, time.Hour + case "7d": + window, bucket = 7*24*time.Hour, 12*time.Hour + case "30d": + window, bucket = 30*24*time.Hour, 24*time.Hour + default: + return ChannelMonitorV2Filter{}, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidRange, rangeValue) + } + start, end := now.Add(-window), now + if bucket > time.Minute { + // Align display windows to a fixed number of whole buckets. The trailing + // bucket may point slightly into the future; SQL simply has no future rows, + // while the current partial bucket can still be shown and recomputed often. + end = now.Truncate(bucket).Add(bucket) + start = end.Add(-window) + } + return ChannelMonitorV2Filter{ + Range: rangeValue, Platforms: normalizeStringSet(platforms), Models: normalizeStringSet(models), GroupIDs: normalizeInt64Set(groupIDs), + Start: start, End: end, Bucket: bucket, + }, nil +} + +func (s *ChannelMonitorV2Service) Dimensions(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2Dimensions, error) { + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + dims, err := s.repo.GetDimensions(ctx, filter, *cfg) + if err != nil { + return nil, err + } + // Dimension request_count is operational volume; strip for non-admin callers + // at the API edge. Dimensions is shared by user/admin routes — redaction is + // applied in the handler for user routes only, so keep raw here. + return dims, nil +} + +func (s *ChannelMonitorV2Service) Snapshot(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2Snapshot, error) { + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + snap, err := s.repo.GetSnapshot(ctx, filter, *cfg, admin) + if err != nil { + return nil, err + } + if !admin && snap != nil { + redactChannelMonitorV2Snapshot(snap, s.hideThroughputForViewer(ctx, admin)) + } + return snap, nil +} + +func (s *ChannelMonitorV2Service) Models(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) { + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + list, err := s.repo.GetModels(ctx, filter, *cfg, admin) + if err != nil { + return nil, err + } + if !admin && list != nil { + hideTP := s.hideThroughputForViewer(ctx, admin) + for i := range list.Items { + redactChannelMonitorV2Metric(&list.Items[i].Metrics, hideTP) + } + } + return list, nil +} + +func (s *ChannelMonitorV2Service) Matrix(ctx context.Context, filter ChannelMonitorV2Filter, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) { + if !groupBy.Valid() { + return nil, fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, groupBy) + } + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + matrix, err := s.repo.GetMatrix(ctx, filter, *cfg, groupBy, admin) + if err != nil { + return nil, err + } + if !admin && matrix != nil { + hideTP := s.hideThroughputForViewer(ctx, admin) + for i := range matrix.Items { + redactChannelMonitorV2Metric(&matrix.Items[i].Metrics, hideTP) + for j := range matrix.Items[i].Buckets { + redactChannelMonitorV2Metric(&matrix.Items[i].Buckets[j].Metrics, hideTP) + } + } + } + return matrix, nil +} + +func ParseChannelMonitorV2GroupBy(value string) (ChannelMonitorV2GroupBy, error) { + groupBy := ChannelMonitorV2GroupBy(strings.TrimSpace(value)) + if groupBy == "" { + // Default presentation: platform / group (matches operator mental model). + groupBy = ChannelMonitorV2GroupByPlatformGroup + } + if !groupBy.Valid() { + return "", fmt.Errorf("%w: %s", ErrChannelMonitorV2InvalidGroupBy, value) + } + return groupBy, nil +} + +func (g ChannelMonitorV2GroupBy) Valid() bool { + switch g { + case ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel: + return true + default: + return false + } +} + +func (s *ChannelMonitorV2Service) Errors(ctx context.Context, filter ChannelMonitorV2Filter) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) { + return s.ErrorsForViewer(ctx, filter, false) +} + +// ErrorsForViewer returns the error breakdown. +// Non-admin callers receive category rates + ignored flags only: absolute Count +// is zeroed and Details (upstream messages / status codes / volume) are omitted. +func (s *ChannelMonitorV2Service) ErrorsForViewer(ctx context.Context, filter ChannelMonitorV2Filter, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) { + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + list, err := s.repo.GetErrors(ctx, filter, *cfg, admin) + if err != nil { + return nil, err + } + if !admin && list != nil { + for i := range list.Items { + list.Items[i].Count = 0 + list.Items[i].Details = nil + } + } + return list, nil +} + +// RedactChannelMonitorV2Dimensions clears absolute request counts on filter chips. +func RedactChannelMonitorV2Dimensions(dims *ChannelMonitorV2Dimensions) { + if dims == nil { + return + } + for i := range dims.Platforms { + dims.Platforms[i].RequestCount = 0 + } + for i := range dims.Models { + dims.Models[i].RequestCount = 0 + } + for i := range dims.Groups { + dims.Groups[i].RequestCount = 0 + } +} + +func redactChannelMonitorV2Snapshot(snap *ChannelMonitorV2Snapshot, hideThroughput bool) { + if snap == nil { + return + } + redactChannelMonitorV2Metric(&snap.Metrics, hideThroughput) + for i := range snap.Trend { + redactChannelMonitorV2Metric(&snap.Trend[i].Metrics, hideThroughput) + } + // Public snapshot only needs display thresholds + refresh cadence, not + // operational allow-lists (group_ids, model inventories, ignored categories). + redactChannelMonitorV2PublicConfig(&snap.Config) +} + +// redactChannelMonitorV2PublicConfig strips operator-policy fields from config +// embedded in user-facing snapshots. Full config remains on admin /config. +func redactChannelMonitorV2PublicConfig(cfg *ChannelMonitorV2Config) { + if cfg == nil { + return + } + cfg.GroupIDs = nil + cfg.IgnoredErrorCategories = nil + cfg.UpdatedBy = nil + for i := range cfg.Platforms { + cfg.Platforms[i].Models = nil + } +} + +// redactChannelMonitorV2Metric zeros absolute volume counters while keeping rates +// (error_rate, success_rate, cache_rate) and latency percentiles. +// When hideThroughput is true, also zeros RPM/TPM so users cannot reverse-estimate +// fleet scale from rate × window length. +func redactChannelMonitorV2Metric(m *ChannelMonitorV2Metric, hideThroughput bool) { + if m == nil { + return + } + m.SuccessRequests = 0 + m.ErrorRequests = 0 + m.RequestCount = 0 + m.InputTokens = 0 + m.OutputTokens = 0 + m.CacheCreationTokens = 0 + m.CacheReadTokens = 0 + m.TokenCount = 0 + m.CacheRateNumerator = 0 + m.CacheRateDenominator = 0 + // Latency sample_count is also a volume signal. + m.TTFT.SampleCount = 0 + m.Duration.SampleCount = 0 + m.UpstreamAffectedRequests = nil + m.UpstreamAttemptCount = nil + if hideThroughput { + m.RPM = 0 + m.TPM = 0 + } +} + +func (s *ChannelMonitorV2Service) Users(ctx context.Context, filter ChannelMonitorV2Filter, viewerID int64, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) { + cfg, err := s.getEnabledConfig(ctx) + if err != nil { + return nil, err + } + result, err := s.repo.GetUsers(ctx, filter, *cfg, admin) + if err != nil { + return nil, err + } + if result == nil { + result = &ChannelMonitorV2List[ChannelMonitorV2UserRow]{} + } + selfIndex := -1 + for i := range result.Items { + result.Items[i].Rank = i + 1 + if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID { + selfIndex = i + result.Items[i].IsSelf = true + } + } + // Viewer with no traffic in this window is still shown (and highlighted) so + // ranking always answers "where am I?" — not only when already in the top list. + if selfIndex < 0 && viewerID > 0 { + id := viewerID + selfRow := ChannelMonitorV2UserRow{ + UserID: &id, + Rank: 0, // unranked / no traffic in window + IsSelf: true, + CanDrilldown: true, + DisplayLabel: "Me", + Metrics: ChannelMonitorV2Metric{}, + } + result.Items = append(result.Items, selfRow) + selfIndex = len(result.Items) - 1 + } + result.Items = channelMonitorV2TopUsersWithSelf(result.Items, selfIndex, 10) + hideTP := s.hideThroughputForViewer(ctx, admin) + if admin { + // Keep identity for admin; still mark self for UI highlight. + for i := range result.Items { + if result.Items[i].UserID != nil && *result.Items[i].UserID == viewerID { + result.Items[i].IsSelf = true + if result.Items[i].DisplayLabel == "" || result.Items[i].DisplayLabel == "Me" { + // Prefer real label when available from repo. + if result.Items[i].Username != "" { + result.Items[i].DisplayLabel = result.Items[i].Username + } else if result.Items[i].Email != "" { + result.Items[i].DisplayLabel = result.Items[i].Email + } else { + result.Items[i].DisplayLabel = "Me" + } + } + } + } + return result, nil + } + for i := range result.Items { + redactChannelMonitorV2Metric(&result.Items[i].Metrics, hideTP) + } + for i := range result.Items { + row := &result.Items[i] + if row.UserID != nil && *row.UserID == viewerID { + row.IsSelf, row.CanDrilldown, row.DisplayLabel = true, true, "Me" + continue + } + row.UserID, row.Email, row.Username, row.CanDrilldown = nil, "", "", false + row.DisplayLabel = fmt.Sprintf("Other user #%d", i+1) + } + return result, nil +} + +func channelMonitorV2TopUsersWithSelf(items []ChannelMonitorV2UserRow, selfIndex int, limit int) []ChannelMonitorV2UserRow { + if limit <= 0 { + return items + } + if len(items) <= limit { + return items + } + out := append([]ChannelMonitorV2UserRow(nil), items[:limit]...) + if selfIndex >= limit && selfIndex < len(items) { + out = append(out, items[selfIndex]) + } + return out +} + +func normalizeChannelMonitorV2Config(cfg *ChannelMonitorV2Config) error { + if cfg.RefreshIntervalSeconds == 0 { + cfg.RefreshIntervalSeconds = 300 + } + if cfg.RefreshIntervalSeconds != 60 && cfg.RefreshIntervalSeconds != 300 { + return fmt.Errorf("%w: refresh_interval_seconds must be 60 or 300", ErrChannelMonitorV2InvalidConfig) + } + var err error + cfg.GroupIDs, err = normalizeChannelMonitorV2GroupIDs(cfg.GroupIDs) + if err != nil { + return err + } + cfg.IgnoredErrorCategories = normalizeChannelMonitorV2IgnoredCategories(cfg.IgnoredErrorCategories) + cfg.HealthThresholds = NormalizeChannelMonitorV2HealthThresholds(cfg.HealthThresholds) + seen := make(map[string]struct{}, len(cfg.Platforms)) + for i := range cfg.Platforms { + p := &cfg.Platforms[i] + p.Platform = strings.ToLower(strings.TrimSpace(p.Platform)) + if p.Platform == "" { + return fmt.Errorf("%w: empty platform", ErrChannelMonitorV2InvalidConfig) + } + if _, ok := seen[p.Platform]; ok { + return fmt.Errorf("%w: duplicate platform %s", ErrChannelMonitorV2InvalidConfig, p.Platform) + } + seen[p.Platform] = struct{}{} + p.Models = normalizeStringSet(p.Models) + } + sort.Slice(cfg.Platforms, func(i, j int) bool { return cfg.Platforms[i].Platform < cfg.Platforms[j].Platform }) + return nil +} + +// DefaultChannelMonitorV2IgnoredErrorCategories are factory defaults for +// ignored_error_categories: excluded from error_rate / health scoring only. +// Operators can clear or extend via admin config. +var DefaultChannelMonitorV2IgnoredErrorCategories = []string{ + "authentication", + "client_cancelled", + "content_policy", + "context_limit", + "group_access", + "model_unsupported", + "not_found", + "quota_or_balance", +} + +func DefaultChannelMonitorV2HealthThresholds() ChannelMonitorV2HealthThresholds { + return ChannelMonitorV2HealthThresholds{ + MinimumSample: 50, + WarningErrorRate: 0.05, + CriticalErrorRate: 0.20, + TargetTTFTMs: 3000, + WarningTTFTMs: 3000, + CriticalTTFTMs: 10000, + // A zero/zero cache threshold means cache misses do not affect health + // until an operator explicitly configures cache scoring. + WarningCacheRate: 0, + CriticalCacheRate: 0, + ErrorWeight: 0.60, + TTFTWeight: 0.20, + CacheWeight: 0.20, + } +} + +func NormalizeChannelMonitorV2HealthThresholds(in ChannelMonitorV2HealthThresholds) ChannelMonitorV2HealthThresholds { + def := DefaultChannelMonitorV2HealthThresholds() + if in.MinimumSample <= 0 { + in.MinimumSample = def.MinimumSample + } + if in.MinimumSample < 1 { + in.MinimumSample = 1 + } + if in.MinimumSample > 10000 { + in.MinimumSample = 10000 + } + if in.WarningErrorRate <= 0 { + in.WarningErrorRate = def.WarningErrorRate + } + if in.CriticalErrorRate <= 0 { + in.CriticalErrorRate = def.CriticalErrorRate + } + if in.CriticalErrorRate < in.WarningErrorRate { + in.CriticalErrorRate = in.WarningErrorRate + } + if in.TargetTTFTMs <= 0 { + in.TargetTTFTMs = def.TargetTTFTMs + } + if in.WarningTTFTMs <= 0 { + in.WarningTTFTMs = def.WarningTTFTMs + } + if in.WarningTTFTMs < in.TargetTTFTMs { + in.WarningTTFTMs = in.TargetTTFTMs + 1 + } + if in.CriticalTTFTMs <= 0 { + in.CriticalTTFTMs = def.CriticalTTFTMs + } + if in.CriticalTTFTMs < in.WarningTTFTMs { + in.CriticalTTFTMs = in.WarningTTFTMs + } + if in.WarningCacheRate < 0 { + in.WarningCacheRate = 0 + } + if in.CriticalCacheRate < 0 { + in.CriticalCacheRate = 0 + } + if in.WarningCacheRate > 1 { + in.WarningCacheRate = 1 + } + if in.CriticalCacheRate > 1 { + in.CriticalCacheRate = 1 + } + if in.CriticalCacheRate > in.WarningCacheRate { + in.CriticalCacheRate = in.WarningCacheRate + } + if in.ErrorWeight <= 0 && in.TTFTWeight <= 0 && in.CacheWeight <= 0 { + in.ErrorWeight, in.TTFTWeight, in.CacheWeight = def.ErrorWeight, def.TTFTWeight, def.CacheWeight + } + return in +} + +func normalizeChannelMonitorV2IgnoredCategories(values []string) []string { + allowed := make(map[string]struct{}, len(ChannelMonitorV2ErrorCategories)) + for _, c := range ChannelMonitorV2ErrorCategories { + allowed[c] = struct{}{} + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + if _, ok := allowed[value]; !ok { + // Unknown labels are dropped so a typo cannot silently create a new category. + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} + +// ChannelMonitorV2IgnoredCategorySet returns a set for O(1) membership checks. +func ChannelMonitorV2IgnoredCategorySet(cfg ChannelMonitorV2Config) map[string]struct{} { + set := make(map[string]struct{}, len(cfg.IgnoredErrorCategories)) + for _, c := range cfg.IgnoredErrorCategories { + set[c] = struct{}{} + } + return set +} + +func normalizeStringSet(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} + +func normalizeInt64Set(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func normalizeChannelMonitorV2GroupIDs(values []int64) ([]int64, error) { + seen := make(map[int64]struct{}, len(values)) + out := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 { + return nil, fmt.Errorf("%w: group_ids must be positive", ErrChannelMonitorV2InvalidConfig) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func ChannelMonitorV2HealthFor(metrics ChannelMonitorV2Metric) ChannelMonitorV2Health { + return ChannelMonitorV2HealthForWithThresholds(metrics, DefaultChannelMonitorV2HealthThresholds()) +} + +func ChannelMonitorV2HealthForWithThresholds(metrics ChannelMonitorV2Metric, thresholds ChannelMonitorV2HealthThresholds) ChannelMonitorV2Health { + thresholds = NormalizeChannelMonitorV2HealthThresholds(thresholds) + result := ChannelMonitorV2Health{ + Overall: "unknown", ErrorRate: "unknown", TTFT: "unknown", Cache: "unknown", + MinimumSample: thresholds.MinimumSample, Thresholds: thresholds, + } + + type scored struct { + score float64 + weight float64 + band string + } + parts := make([]scored, 0, 3) + + if metrics.RequestCount >= result.MinimumSample { + s := errorRateScore(metrics.ErrorRate, thresholds.CriticalErrorRate) + result.ErrorRateScore = &s + result.ErrorRate = healthBand(metrics.ErrorRate, thresholds.WarningErrorRate, thresholds.CriticalErrorRate) + parts = append(parts, scored{score: s, weight: thresholds.ErrorWeight, band: result.ErrorRate}) + } + // Prefer p50 for TTFT scoring; fall back to p95 only if p50 is missing. + if metrics.TTFT.SampleCount >= result.MinimumSample { + var ttftMs *int64 + if metrics.TTFT.P50Ms != nil { + ttftMs = metrics.TTFT.P50Ms + } else if metrics.TTFT.P95Ms != nil { + ttftMs = metrics.TTFT.P95Ms + } + if ttftMs != nil { + s := ttftP50Score(float64(*ttftMs), float64(thresholds.TargetTTFTMs), float64(thresholds.CriticalTTFTMs)) + result.TTFTScore = &s + result.TTFT = healthBand(float64(*ttftMs), float64(thresholds.WarningTTFTMs), float64(thresholds.CriticalTTFTMs)) + parts = append(parts, scored{score: s, weight: thresholds.TTFTWeight, band: result.TTFT}) + } + } + // Cache: need a meaningful denominator; higher rate is better. + if metrics.CacheRateDenominator >= result.MinimumSample { + s := cacheRateScore(metrics.CacheRate) + if thresholds.WarningCacheRate <= 0 && thresholds.CriticalCacheRate <= 0 { + // A zero/zero cache threshold means "do not penalize cache misses". + s = 100 + } + result.CacheScore = &s + // Invert for healthBand (lower is worse): use (1 - rate) against warning/critical floors. + result.Cache = cacheRateBand(metrics.CacheRate, thresholds.WarningCacheRate, thresholds.CriticalCacheRate) + parts = append(parts, scored{score: s, weight: thresholds.CacheWeight, band: result.Cache}) + } + + if len(parts) == 0 { + return result + } + var weightSum, scoreSum float64 + for _, p := range parts { + weightSum += p.weight + scoreSum += p.weight * p.score + } + if weightSum <= 0 { + return result + } + overall := scoreSum / weightSum + result.Score = &overall + result.Overall = scoreBand(overall) + return result +} + +// errorRateScore maps error rate to 0–100. 0% → 100; at/above critical → 0 (linear). +func errorRateScore(errorRate, critical float64) float64 { + if critical <= 0 { + critical = 0.05 + } + if errorRate <= 0 { + return 100 + } + if errorRate >= critical { + return 0 + } + return 100 * (1 - errorRate/critical) +} + +// ttftP50Score maps TTFT p50 ms to 0–100. +// At/below target → 100; at/above critical → 0; linear in between. +func ttftP50Score(p50Ms, targetMs, criticalMs float64) float64 { + if targetMs <= 0 { + targetMs = 2500 + } + if criticalMs <= targetMs { + criticalMs = targetMs * 2.4 + } + if p50Ms <= targetMs { + return 100 + } + if p50Ms >= criticalMs { + return 0 + } + return 100 * (1 - (p50Ms-targetMs)/(criticalMs-targetMs)) +} + +// cacheRateScore maps cache hit rate to 0–100 (higher is better, linear). +func cacheRateScore(cacheRate float64) float64 { + if cacheRate <= 0 { + return 0 + } + if cacheRate >= 1 { + return 100 + } + return 100 * cacheRate +} + +// cacheRateBand: below critical → critical; below warning → warning; else healthy. +func cacheRateBand(cacheRate, warning, critical float64) string { + if cacheRate < critical { + return "critical" + } + if cacheRate < warning { + return "warning" + } + return "healthy" +} + +// scoreBand maps continuous 0–100 scores to coarse labels for legacy consumers. +func scoreBand(score float64) string { + switch { + case score >= 80: + return "healthy" + case score >= 50: + return "warning" + default: + return "critical" + } +} + +func healthBand(value, warning, critical float64) string { + if value >= critical { + return "critical" + } + if value >= warning { + return "warning" + } + return "healthy" +} diff --git a/backend/internal/service/channel_monitor_v2_aggregator.go b/backend/internal/service/channel_monitor_v2_aggregator.go new file mode 100644 index 0000000000..3c18b03756 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_aggregator.go @@ -0,0 +1,419 @@ +package service + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" +) + +const ( + channelMonitorV2AggregatorLockKey = "channel-monitor-v2-aggregator" + // Retention walks back to the longest stored tier (1d rollup = 90d). Per-tier + // prune in the repository drops short-lived 1m/user/hist facts earlier. + channelMonitorV2RetentionMax = 90 * 24 * time.Hour + // First tick after upgrade prioritizes the default 90m view (with small padding). + channelMonitorV2BootstrapFirst = 2 * time.Hour + // Always refresh a small trailing window so late writes land without + // re-aggregating large history every tick. + channelMonitorV2RecentOverlap = 10 * time.Minute + + // Gentle backfill: small adaptive chunks, never default 24h hammering. + // Initial historical chunk after the 2h seed. + channelMonitorV2BackfillChunkInit = time.Hour + channelMonitorV2MinBackfillChunk = 15 * time.Minute + // Depth-based ceilings (product phases 90m → 1d → 7d → 30d → 90d). + channelMonitorV2MaxChunkNear1d = 2 * time.Hour + channelMonitorV2MaxChunkNear7d = 4 * time.Hour + channelMonitorV2MaxChunkFar = 6 * time.Hour + + // Soft adaptive timing: grow only when a recompute is clearly cheap. + channelMonitorV2GrowChunkUnder = 15 * time.Second + channelMonitorV2MaxBackoff = 10 * time.Minute +) + +// channelMonitorRuntimeSubscriber is the optional settings hook that lets the +// aggregator wake immediately when channel_monitor_enabled / mode flips. +type channelMonitorRuntimeSubscriber interface { + SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) +} + +type ChannelMonitorV2Aggregator struct { + repo ChannelMonitorV2Repository + db *sql.DB + settings channelMonitorRuntimeReader + instanceID string + stopCh chan struct{} + // kickCh wakes the loop early after a settings change (buffered 1). + kickCh chan struct{} + startOnce sync.Once + stopOnce sync.Once + mu sync.Mutex + // backfillAt is the earliest minute already recomputed (mirrors DB cursor). + // Zero means "not yet loaded from durable watermark this process". + backfillAt time.Time + backfillChunk time.Duration + backfillFailures int + // nextWaitFloor is applied after runOnce when failures require backoff. + nextWaitFloor time.Duration + // cursorLoaded is true after the first successful watermark read (or init). + cursorLoaded bool + // hasAggregated is true once any recompute in this process (or durable data) exists. + hasAggregated bool + unsub func() + ctx context.Context + cancel context.CancelFunc +} + +func NewChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settings channelMonitorRuntimeReader) *ChannelMonitorV2Aggregator { + return &ChannelMonitorV2Aggregator{ + repo: repo, + db: db, + settings: settings, + instanceID: uuid.NewString(), + stopCh: make(chan struct{}), + kickCh: make(chan struct{}, 1), + backfillChunk: channelMonitorV2BackfillChunkInit, + } +} + +func (s *ChannelMonitorV2Aggregator) Start() { + if s == nil || s.repo == nil { + return + } + s.startOnce.Do(func() { + s.mu.Lock() + s.ctx, s.cancel = context.WithCancel(context.Background()) + s.mu.Unlock() + if sub, ok := s.settings.(channelMonitorRuntimeSubscriber); ok && sub != nil { + unsub := sub.SubscribeChannelMonitorRuntime(func() { + s.kick() + }) + s.mu.Lock() + stopped := s.ctx == nil + if !stopped { + select { + case <-s.ctx.Done(): + stopped = true + default: + } + } + if !stopped { + s.unsub = unsub + } + s.mu.Unlock() + if stopped && unsub != nil { + unsub() + } + } + go s.loop() + }) +} + +func (s *ChannelMonitorV2Aggregator) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + s.mu.Lock() + cancel := s.cancel + unsub := s.unsub + s.cancel = nil + s.unsub = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + if unsub != nil { + unsub() + } + close(s.stopCh) + }) +} + +// kick wakes the aggregation loop so mode flips take effect without waiting +// for the next refresh interval. +func (s *ChannelMonitorV2Aggregator) kick() { + if s == nil { + return + } + select { + case s.kickCh <- struct{}{}: + default: + } +} + +func (s *ChannelMonitorV2Aggregator) loop() { + for { + interval := time.Minute + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + if !s.passiveAggregationAllowed(ctx) { + cancel() + if !s.wait(interval) { + return + } + continue + } + if cfg, err := s.repo.GetConfig(ctx); err == nil { + if !cfg.Enabled { + cancel() + if !s.wait(interval) { + return + } + continue + } + if cfg.RefreshIntervalSeconds > 0 { + interval = time.Duration(cfg.RefreshIntervalSeconds) * time.Second + } + } + cancel() + s.runOnce() + // Hard gate: never compress bootstrap to multi-Hz ticks. Soft gate: on + // repeated failures raise the wait floor (exponential backoff). + s.mu.Lock() + floor := s.nextWaitFloor + s.mu.Unlock() + if floor > interval { + interval = floor + } + if !s.wait(interval) { + return + } + } +} + +func (s *ChannelMonitorV2Aggregator) passiveAggregationAllowed(ctx context.Context) bool { + if s == nil || s.settings == nil { + // Fail closed without settings: do not aggregate under ambiguous mode. + return false + } + return s.settings.GetChannelMonitorRuntime(ctx).PassiveAggregationAllowed() +} + +func (s *ChannelMonitorV2Aggregator) wait(interval time.Duration) bool { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-s.kickCh: + // Drain any coalesced kicks so a burst of settings writes only wakes once. + for { + select { + case <-s.kickCh: + default: + return true + } + } + case <-s.stopCh: + return false + } +} + +func (s *ChannelMonitorV2Aggregator) runOnce() { + s.mu.Lock() + parent := s.ctx + s.mu.Unlock() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, 55*time.Second) + defer cancel() + release, acquired := tryAcquireSingletonLeaderLock(ctx, nil, s.db, channelMonitorV2AggregatorLockKey, s.instanceID, 2*time.Minute) + if !acquired { + return + } + if release != nil { + defer release() + } + + now := time.Now().UTC().Truncate(time.Minute) + if err := s.ensureCursor(ctx, now); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] load watermark failed: %v", err) + return + } + + s.mu.Lock() + cursor := s.backfillAt + hasData := s.hasAggregated + s.mu.Unlock() + + // Phase 1 (first upgrade / empty): seed the default 90m UI window quickly. + if !hasData || cursor.IsZero() { + start := now.Add(-channelMonitorV2BootstrapFirst) + started := time.Now() + if err := s.repo.RecomputeRange(ctx, start, now); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] bootstrap recent aggregation failed: %v", err) + s.recordBackfillFailure(now, cursor) + return + } + s.recordBackfillSuccess(start, time.Since(started), now) + return + } + + // Always refresh the trailing overlap so late usage/error writes land in 1m facts. + if err := s.repo.RecomputeRange(ctx, now.Add(-channelMonitorV2RecentOverlap), now); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] overlap aggregation failed: %v", err) + return + } + + // Phase 2: walk history backward at most one chunk per tick until retention max (90d). + // Product UI (30d) fills first; remaining 30–90d continues silently. + retentionCutoff := now.Add(-channelMonitorV2RetentionMax) + if !cursor.After(retentionCutoff) { + return + } + end := cursor + s.mu.Lock() + chunk := s.backfillChunk + s.mu.Unlock() + if chunk <= 0 { + chunk = channelMonitorV2BackfillChunkInit + } + maxChunk := channelMonitorV2MaxChunkForDepth(now, end) + if chunk > maxChunk { + chunk = maxChunk + } + if chunk < channelMonitorV2MinBackfillChunk { + chunk = channelMonitorV2MinBackfillChunk + } + start := end.Add(-chunk) + // Once bootstrap reaches historical data, keep chunks on day boundaries so + // daily rollups never depend on 1m rows from two independently pruned chunks. + if end.Before(now.Add(-7 * 24 * time.Hour)) { + aligned := end.Add(-chunk).Truncate(24 * time.Hour) + if aligned.Before(end) { + start = aligned + } + } + if start.Before(retentionCutoff) { + start = retentionCutoff + } + if !start.Before(end) { + return + } + started := time.Now() + if err := s.repo.RecomputeRange(ctx, start, end); err != nil { + logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] backfill failed %s..%s: %v", start, end, err) + s.recordBackfillFailure(now, end) + return + } + s.recordBackfillSuccess(start, time.Since(started), now) +} + +// channelMonitorV2MaxChunkForDepth returns the hard ceiling for a historical +// chunk ending at `end` (earliest already covered / next walk end). +func channelMonitorV2MaxChunkForDepth(now, end time.Time) time.Duration { + age := now.Sub(end) + switch { + case age < 24*time.Hour: + return channelMonitorV2MaxChunkNear1d + case age < 7*24*time.Hour: + return channelMonitorV2MaxChunkNear7d + default: + return channelMonitorV2MaxChunkFar + } +} + +func (s *ChannelMonitorV2Aggregator) recordBackfillSuccess(coveredFrom time.Time, elapsed time.Duration, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.backfillAt = coveredFrom + s.hasAggregated = true + s.backfillFailures = 0 + s.nextWaitFloor = 0 + maxChunk := channelMonitorV2MaxChunkForDepth(now, coveredFrom) + // Grow slowly only when the recompute was clearly cheap. + if elapsed > 0 && elapsed < channelMonitorV2GrowChunkUnder { + next := s.backfillChunk + if next <= 0 { + next = channelMonitorV2BackfillChunkInit + } + next = time.Duration(float64(next) * 1.5) + if next > maxChunk { + next = maxChunk + } + if next < channelMonitorV2MinBackfillChunk { + next = channelMonitorV2MinBackfillChunk + } + s.backfillChunk = next + return + } + // Keep a healthy chunk within the depth ceiling after success. + if s.backfillChunk <= 0 || s.backfillChunk > maxChunk { + s.backfillChunk = channelMonitorV2BackfillChunkInit + if s.backfillChunk > maxChunk { + s.backfillChunk = maxChunk + } + } +} + +func (s *ChannelMonitorV2Aggregator) recordBackfillFailure(now, end time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.backfillFailures++ + // Shrink chunk toward the minimum so large DBs self-throttle. + if s.backfillChunk <= 0 { + s.backfillChunk = channelMonitorV2BackfillChunkInit + } + s.backfillChunk /= 2 + if s.backfillChunk < channelMonitorV2MinBackfillChunk { + s.backfillChunk = channelMonitorV2MinBackfillChunk + } + maxChunk := channelMonitorV2MaxChunkForDepth(now, end) + if s.backfillChunk > maxChunk { + s.backfillChunk = maxChunk + } + // Exponential backoff on wait floor: 1m, 2m, 4m… capped at 10m. + floor := time.Minute << uint(s.backfillFailures-1) + if floor > channelMonitorV2MaxBackoff { + floor = channelMonitorV2MaxBackoff + } + if floor < time.Minute { + floor = time.Minute + } + s.nextWaitFloor = floor +} + +// ensureCursor restores durable backfill_cursor after process restart so progress +// and historical walk continue instead of re-seeding only the last 2h. +func (s *ChannelMonitorV2Aggregator) ensureCursor(ctx context.Context, now time.Time) error { + s.mu.Lock() + loaded := s.cursorLoaded + s.mu.Unlock() + if loaded { + return nil + } + wm, err := s.repo.GetAggregationWatermark(ctx) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.cursorLoaded { + return nil + } + if wm != nil { + if !wm.BackfillCursor.IsZero() { + s.backfillAt = wm.BackfillCursor.UTC().Truncate(time.Minute) + } + if wm.HasData || !wm.DataThrough.IsZero() { + s.hasAggregated = true + // Legacy rows may have data_through but null backfill_cursor (older workers). + // Infer cursor from data_through − initial window so we do not re-bootstrap + // only 2h and claim zero progress forever. + if s.backfillAt.IsZero() && !wm.DataThrough.IsZero() { + inferred := wm.DataThrough.UTC().Truncate(time.Minute).Add(-channelMonitorV2BootstrapFirst) + if inferred.After(now) { + inferred = now.Add(-channelMonitorV2BootstrapFirst) + } + s.backfillAt = inferred + } + } + } + s.cursorLoaded = true + return nil +} diff --git a/backend/internal/service/channel_monitor_v2_aggregator_test.go b/backend/internal/service/channel_monitor_v2_aggregator_test.go new file mode 100644 index 0000000000..5fc6b47fd4 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_aggregator_test.go @@ -0,0 +1,49 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorV2MaxChunkForDepth(t *testing.T) { + now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) + + // Within last day → tightest ceiling (2h). + require.Equal(t, channelMonitorV2MaxChunkNear1d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*time.Hour))) + // Between 1d and 7d → 4h. + require.Equal(t, channelMonitorV2MaxChunkNear7d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*24*time.Hour))) + // Older than 7d → 6h (never 24h default). + require.Equal(t, channelMonitorV2MaxChunkFar, channelMonitorV2MaxChunkForDepth(now, now.Add(-10*24*time.Hour))) + require.Less(t, channelMonitorV2MaxChunkFar, 24*time.Hour) + require.Equal(t, time.Hour, channelMonitorV2BackfillChunkInit) + require.Equal(t, 15*time.Minute, channelMonitorV2MinBackfillChunk) +} + +func TestChannelMonitorV2AggregatorAdaptiveChunk(t *testing.T) { + s := NewChannelMonitorV2Aggregator(nil, nil, nil) + now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) + cursor := now.Add(-3 * time.Hour) + + // Failure shrinks chunk and sets backoff floor. + s.backfillChunk = 2 * time.Hour + s.recordBackfillFailure(now, cursor) + require.Equal(t, time.Hour, s.backfillChunk) + require.Equal(t, time.Minute, s.nextWaitFloor) + require.Equal(t, 1, s.backfillFailures) + + // Repeated failure halves again and raises floor. + s.recordBackfillFailure(now, cursor) + require.Equal(t, 30*time.Minute, s.backfillChunk) + require.Equal(t, 2*time.Minute, s.nextWaitFloor) + + // Fast success grows within depth ceiling and clears backoff. + s.recordBackfillSuccess(cursor.Add(-30*time.Minute), 5*time.Second, now) + require.Equal(t, 0, s.backfillFailures) + require.Equal(t, time.Duration(0), s.nextWaitFloor) + require.Greater(t, s.backfillChunk, 30*time.Minute) + require.LessOrEqual(t, s.backfillChunk, channelMonitorV2MaxChunkForDepth(now, cursor.Add(-30*time.Minute))) +} diff --git a/backend/internal/service/channel_monitor_v2_error_taxonomy.go b/backend/internal/service/channel_monitor_v2_error_taxonomy.go new file mode 100644 index 0000000000..403458e162 --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_error_taxonomy.go @@ -0,0 +1,79 @@ +package service + +import "strings" + +type ChannelMonitorV2ErrorInput struct { + ErrorType string + ErrorOwner string + ErrorSource string + StatusCode int + UpstreamStatusCode int + Message string +} + +// ClassifyChannelMonitorV2Error is deliberately ordered. Once rows are stored +// with a taxonomy version, changing this order requires a new version. +func ClassifyChannelMonitorV2Error(input ChannelMonitorV2ErrorInput) string { + errorType := strings.ToLower(strings.TrimSpace(input.ErrorType)) + owner := strings.ToLower(strings.TrimSpace(input.ErrorOwner)) + text := strings.ToLower(strings.Join([]string{input.ErrorType, input.ErrorSource, input.Message}, " ")) + + if errorType == "cyber_policy" || channelMonitorV2ContainsAny(text, "content policy", "content_policy", "safety policy", "moderation", "blocked keyword") { + return "content_policy" + } + if input.StatusCode == 401 || input.UpstreamStatusCode == 401 || channelMonitorV2ContainsAny(text, "unauthorized", "invalid api key", "invalid_api_key", "authentication", "api_key_disabled") { + return "authentication" + } + if channelMonitorV2ContainsAny(text, "context window", "context length", "maximum prompt length", "too many tokens", "max_tokens") { + return "context_limit" + } + if channelMonitorV2ContainsAny(text, "failed to deserialize", "missing required parameter", "invalid request", "invalid_request", "tool_choice") { + return "invalid_request" + } + if channelMonitorV2ContainsAny(text, "does not support the requested model", "not supported by any configured account", "model not supported", "unsupported model") { + return "model_unsupported" + } + if channelMonitorV2ContainsAny(text, "group not allowed", "group_not_allowed", "group access") { + return "group_access" + } + if channelMonitorV2ContainsAny(text, "run out of credits", "insufficient balance", "insufficient quota", "subscription", "quota exceeded", "billing hard limit") { + return "quota_or_balance" + } + if channelMonitorV2ContainsAny(text, "no available accounts", "no healthy account", "no healthy upstream account", "failover budget exhausted", "account pool") { + return "account_pool_unavailable" + } + if input.StatusCode == 429 || input.UpstreamStatusCode == 429 || channelMonitorV2ContainsAny(text, "rate limit", "rate_limit", "high demand", "overloaded", "concurrency limit", "capacity") { + return "rate_or_capacity" + } + if input.StatusCode == 408 || input.StatusCode == 504 || channelMonitorV2ContainsAny(text, "timeout", "deadline exceeded", "error code: 524", "gateway time-out", "gateway timeout") { + return "timeout" + } + if channelMonitorV2ContainsAny(text, "transport", "stream_read_error", "connection reset", "connection refused", "tls", "http2", "missing terminal event", "unexpected eof") { + return "transport_or_stream" + } + if input.StatusCode == 403 || input.UpstreamStatusCode == 403 { + return "upstream_forbidden" + } + if input.StatusCode == 404 || input.UpstreamStatusCode == 404 { + return "not_found" + } + if input.StatusCode == 499 || channelMonitorV2ContainsAny(text, "client cancelled", "client canceled", "context canceled") { + return "client_cancelled" + } + if input.UpstreamStatusCode >= 500 || (owner == "provider" && input.StatusCode >= 500) { + return "upstream_5xx" + } + if input.StatusCode >= 500 || errorType == "internal" || owner == "system" { + return "internal" + } + return "other" +} + +func channelMonitorV2ContainsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} diff --git a/backend/internal/service/channel_monitor_v2_test.go b/backend/internal/service/channel_monitor_v2_test.go new file mode 100644 index 0000000000..9172b333df --- /dev/null +++ b/backend/internal/service/channel_monitor_v2_test.go @@ -0,0 +1,469 @@ +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type channelMonitorV2RepoStub struct { + config serviceChannelMonitorV2ConfigAlias + users *ChannelMonitorV2List[ChannelMonitorV2UserRow] + matrix *ChannelMonitorV2Matrix + errors *ChannelMonitorV2List[ChannelMonitorV2ErrorRow] + snap *ChannelMonitorV2Snapshot + group ChannelMonitorV2GroupBy + admin bool +} + +// Alias keeps composite literals readable without introducing another package. +type serviceChannelMonitorV2ConfigAlias = ChannelMonitorV2Config + +func (s *channelMonitorV2RepoStub) GetConfig(context.Context) (*ChannelMonitorV2Config, error) { + cfg := ChannelMonitorV2Config(s.config) + return &cfg, nil +} +func (s *channelMonitorV2RepoStub) UpdateConfig(context.Context, ChannelMonitorV2Config, int) (*ChannelMonitorV2Config, error) { + return nil, nil +} +func (s *channelMonitorV2RepoStub) GetDimensions(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error) { + return nil, nil +} +func (s *channelMonitorV2RepoStub) GetSnapshot(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2Snapshot, error) { + s.admin = admin + if s.snap == nil { + return nil, nil + } + // Shallow copy so public redaction does not mutate fixture. + cfg := s.snap.Config + cfg.Platforms = append([]ChannelMonitorV2PlatformConfig(nil), s.snap.Config.Platforms...) + for i := range cfg.Platforms { + cfg.Platforms[i].Models = append([]string(nil), s.snap.Config.Platforms[i].Models...) + } + cfg.GroupIDs = append([]int64(nil), s.snap.Config.GroupIDs...) + cfg.IgnoredErrorCategories = append([]string(nil), s.snap.Config.IgnoredErrorCategories...) + out := *s.snap + out.Config = cfg + return &out, nil +} +func (s *channelMonitorV2RepoStub) GetModels(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) { + return nil, nil +} +func (s *channelMonitorV2RepoStub) GetMatrix(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) { + s.group, s.admin = groupBy, admin + return s.matrix, nil +} +func (s *channelMonitorV2RepoStub) GetErrors(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) { + s.admin = admin + if s.errors != nil { + // Return a shallow copy so service redaction does not mutate the fixture. + out := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{ + Coverage: s.errors.Coverage, + Items: append([]ChannelMonitorV2ErrorRow(nil), s.errors.Items...), + } + for i := range out.Items { + if len(out.Items[i].Details) > 0 { + out.Items[i].Details = append([]ChannelMonitorV2ErrorDetail(nil), out.Items[i].Details...) + } + } + return out, nil + } + return nil, nil +} +func (s *channelMonitorV2RepoStub) GetUsers(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) { + return s.users, nil +} +func (s *channelMonitorV2RepoStub) RecomputeRange(context.Context, time.Time, time.Time) error { + return nil +} +func (s *channelMonitorV2RepoStub) GetAggregationWatermark(context.Context) (*ChannelMonitorV2AggregationWatermark, error) { + return &ChannelMonitorV2AggregationWatermark{}, nil +} + +func TestChannelMonitorV2BootstrapProgress(t *testing.T) { + now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + + t.Run("no data shows active zero progress", func(t *testing.T) { + b := ChannelMonitorV2BootstrapProgress(now, time.Time{}, false) + require.NotNil(t, b) + require.True(t, b.Active) + require.Equal(t, 0, b.ProgressPercent) + require.Equal(t, now.Add(-ChannelMonitorV2BootstrapProductWindow), b.TargetStart) + }) + + t.Run("2h seed is small percent of 30d", func(t *testing.T) { + covered := now.Add(-2 * time.Hour) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.NotNil(t, b) + require.True(t, b.Active) + require.Greater(t, b.ProgressPercent, 0) + require.Less(t, b.ProgressPercent, 5) + }) + + t.Run("30d covered hides bootstrap", func(t *testing.T) { + covered := now.Add(-ChannelMonitorV2BootstrapProductWindow) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.Nil(t, b) + }) + + t.Run("beyond 30d also hides", func(t *testing.T) { + covered := now.Add(-60 * 24 * time.Hour) + b := ChannelMonitorV2BootstrapProgress(now, covered, true) + require.Nil(t, b) + }) +} + +func TestChannelMonitorV2ParseFilterDefaultsAndBuckets(t *testing.T) { + svc := &ChannelMonitorV2Service{now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }} + + filter, err := svc.ParseFilter("", []string{"openai", "openai", ""}, []string{"gpt-5"}, []int64{2, 1, 2, 0}) + require.NoError(t, err) + require.Equal(t, "90m", filter.Range) + require.Equal(t, 5*time.Minute, filter.Bucket) + require.Equal(t, []string{"openai"}, filter.Platforms) + require.Equal(t, []int64{1, 2}, filter.GroupIDs) + require.Equal(t, 90*time.Minute, filter.End.Sub(filter.Start)) + + filter, err = svc.ParseFilter("30d", nil, nil, nil) + require.NoError(t, err) + require.Equal(t, 24*time.Hour, filter.Bucket) + _, err = svc.ParseFilter("15d", nil, nil, nil) + require.ErrorIs(t, err, ErrChannelMonitorV2InvalidRange) +} + +func TestParseChannelMonitorV2GroupBy(t *testing.T) { + groupBy, err := ParseChannelMonitorV2GroupBy("") + require.NoError(t, err) + require.Equal(t, ChannelMonitorV2GroupByPlatformGroup, groupBy) + for _, value := range []ChannelMonitorV2GroupBy{ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel} { + parsed, parseErr := ParseChannelMonitorV2GroupBy(string(value)) + require.NoError(t, parseErr) + require.Equal(t, value, parsed) + } + _, err = ParseChannelMonitorV2GroupBy("group") + require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy) +} + +func TestChannelMonitorV2MatrixForwardsGroupingAndAdminScope(t *testing.T) { + want := &ChannelMonitorV2Matrix{GroupBy: ChannelMonitorV2GroupByPlatformModel} + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, matrix: want} + result, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatformModel, true) + require.NoError(t, err) + require.Same(t, want, result) + require.Equal(t, ChannelMonitorV2GroupByPlatformModel, repo.group) + require.True(t, repo.admin) + + _, err = NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, "bad", false) + require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy) +} + +func TestChannelMonitorV2ConfigValidation(t *testing.T) { + cfg := ChannelMonitorV2Config{ + Platforms: []ChannelMonitorV2PlatformConfig{ + {Platform: " OpenAI ", Enabled: true, Models: []string{"gpt-5", "gpt-5", ""}}, + {Platform: "anthropic", Enabled: true}, + }, + GroupIDs: []int64{3, 1, 3}, + } + require.NoError(t, normalizeChannelMonitorV2Config(&cfg)) + require.Equal(t, 300, cfg.RefreshIntervalSeconds) + require.Equal(t, "anthropic", cfg.Platforms[0].Platform) + require.Equal(t, []int64{1, 3}, cfg.GroupIDs) + + cfg.RefreshIntervalSeconds = 120 + require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig) + + cfg.RefreshIntervalSeconds = 60 + cfg.GroupIDs = []int64{0} + require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig) +} + +func TestChannelMonitorV2ErrorTaxonomyPriority(t *testing.T) { + tests := []struct { + name string + in ChannelMonitorV2ErrorInput + want string + }{ + {"cyber", ChannelMonitorV2ErrorInput{ErrorType: "cyber_policy", StatusCode: 200}, "content_policy"}, + {"auth before forbidden", ChannelMonitorV2ErrorInput{StatusCode: 403, Message: "invalid API key"}, "authentication"}, + {"context", ChannelMonitorV2ErrorInput{Message: "maximum prompt length exceeded"}, "context_limit"}, + {"unsupported", ChannelMonitorV2ErrorInput{Message: "not supported by any configured account"}, "model_unsupported"}, + {"pool", ChannelMonitorV2ErrorInput{Message: "No available accounts"}, "account_pool_unavailable"}, + {"timeout", ChannelMonitorV2ErrorInput{Message: "error code: 524"}, "timeout"}, + {"upstream", ChannelMonitorV2ErrorInput{ErrorOwner: "provider", StatusCode: 502, UpstreamStatusCode: 502}, "upstream_5xx"}, + {"other", ChannelMonitorV2ErrorInput{StatusCode: 400, Message: "unknown"}, "other"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, ClassifyChannelMonitorV2Error(tt.in)) }) + } +} + +func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) { + // error 3%/5% → 40; ttft p50 2s → 100; cache 50% → 50 + // overall = (0.6*40 + 0.2*100 + 0.2*50) / 1.0 = 54 → warning + p50 := int64(2000) + p95 := int64(9000) + thresholds := ChannelMonitorV2HealthThresholds{ + MinimumSample: 20, + WarningErrorRate: 0.02, + CriticalErrorRate: 0.05, + TargetTTFTMs: 2500, + WarningTTFTMs: 2501, + CriticalTTFTMs: 6000, + WarningCacheRate: 0.20, + CriticalCacheRate: 0.05, + ErrorWeight: 0.60, + TTFTWeight: 0.20, + CacheWeight: 0.20, + } + metrics := ChannelMonitorV2Metric{ + RequestCount: 100, + ErrorRate: 0.03, + CacheRate: 0.50, + CacheRateDenominator: 100, + TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50, P95Ms: &p95}, + } + health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds) + require.Equal(t, "warning", health.ErrorRate) + require.Equal(t, "healthy", health.TTFT) + require.Equal(t, "healthy", health.Cache) + require.NotNil(t, health.Score) + require.NotNil(t, health.CacheScore) + require.InDelta(t, 50.0, *health.CacheScore, 0.01) + require.InDelta(t, 54.0, *health.Score, 0.01) + require.Equal(t, "warning", health.Overall) + + // Perfect signals → 100 + p50OK := int64(1000) + metrics.ErrorRate = 0 + metrics.CacheRate = 1 + metrics.TTFT.P50Ms = &p50OK + health = ChannelMonitorV2HealthForWithThresholds(metrics, thresholds) + require.Equal(t, "healthy", health.Overall) + require.NotNil(t, health.Score) + require.InDelta(t, 100.0, *health.Score, 0.01) + + // Small samples stay unknown + health = ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{RequestCount: 2}) + require.Equal(t, "unknown", health.Overall) + require.Nil(t, health.Score) +} + +func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) { + p50 := int64(2500) + health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{ + RequestCount: 100, + ErrorRate: 0.03, + CacheRate: 0, + CacheRateDenominator: 100, + TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50}, + }) + require.Equal(t, "healthy", health.ErrorRate) + require.Equal(t, "healthy", health.TTFT) + require.Equal(t, "healthy", health.Cache) + require.Equal(t, "healthy", health.Overall) + require.NotNil(t, health.CacheScore) + require.InDelta(t, 100.0, *health.CacheScore, 0.01) +} + +func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) { + require.InDelta(t, 100.0, errorRateScore(0, 0.05), 0.001) + require.InDelta(t, 0.0, errorRateScore(0.05, 0.05), 0.001) + require.InDelta(t, 40.0, errorRateScore(0.03, 0.05), 0.001) + + require.InDelta(t, 100.0, ttftP50Score(2500, 2500, 6000), 0.001) + require.InDelta(t, 100.0, ttftP50Score(1000, 2500, 6000), 0.001) + require.InDelta(t, 0.0, ttftP50Score(6000, 2500, 6000), 0.001) + require.InDelta(t, 50.0, ttftP50Score(4250, 2500, 6000), 0.001) + + require.InDelta(t, 0.0, cacheRateScore(0), 0.001) + require.InDelta(t, 100.0, cacheRateScore(1), 0.001) + require.InDelta(t, 50.0, cacheRateScore(0.5), 0.001) + require.Equal(t, "critical", cacheRateBand(0.02, 0.20, 0.05)) + require.Equal(t, "warning", cacheRateBand(0.10, 0.20, 0.05)) + require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05)) +} + +func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) { + selfID, otherID := int64(7), int64(9) + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{ + {UserID: &otherID, Email: "other@example.com", Username: "other"}, + {UserID: &selfID, Email: "self@example.com", Username: "self"}, + }}} + result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false) + require.NoError(t, err) + require.Nil(t, result.Items[0].UserID) + require.Empty(t, result.Items[0].Email) + require.Equal(t, "Other user #1", result.Items[0].DisplayLabel) + require.Equal(t, selfID, *result.Items[1].UserID) + require.True(t, result.Items[1].IsSelf) + require.Equal(t, "Me", result.Items[1].DisplayLabel) +} + +func TestChannelMonitorV2UsersAppendsSelfWhenMissingFromRanking(t *testing.T) { + selfID, otherID := int64(7), int64(9) + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{ + {UserID: &otherID, Email: "other@example.com", Username: "other", Metrics: ChannelMonitorV2Metric{RequestCount: 10}}, + }}} + result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false) + require.NoError(t, err) + require.Len(t, result.Items, 2) + self := result.Items[1] + require.True(t, self.IsSelf) + require.Equal(t, "Me", self.DisplayLabel) + require.Equal(t, selfID, *self.UserID) + require.Equal(t, 0, self.Rank) // unranked / no traffic in window +} + +func TestChannelMonitorV2TopUsersKeepsSelfOutsideLimit(t *testing.T) { + items := make([]ChannelMonitorV2UserRow, 0, 12) + for i := 1; i <= 12; i++ { + id := int64(i) + items = append(items, ChannelMonitorV2UserRow{UserID: &id, Rank: i, DisplayLabel: fmt.Sprintf("u%d", i)}) + } + // self is rank 12 (index 11) + out := channelMonitorV2TopUsersWithSelf(items, 11, 10) + require.Len(t, out, 11) + require.Equal(t, int64(12), *out[10].UserID) + require.Equal(t, 12, out[10].Rank) +} + +func TestChannelMonitorV2ReadAPIsRejectDisabledConfig(t *testing.T) { + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: false}} + _, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatform, false) + require.ErrorIs(t, err, ErrChannelMonitorDisabled) +} + +func TestNormalizeChannelMonitorV2IgnoredCategories(t *testing.T) { + got := normalizeChannelMonitorV2IgnoredCategories([]string{"timeout", "TIMEOUT", "not-a-real", "", "timeout", "other"}) + require.Equal(t, []string{"other", "timeout"}, got) +} + +func TestApplyIgnoredErrorsAdjustsRateOnly(t *testing.T) { + // applyIgnoredErrors lives in repository package; mirror the scored-rate formula. + // scored errors = total_errors - ignored; error_rate = scored / request_count + // success_rate stays SuccessRequests / RequestCount (ignored ≠ success). + m := ChannelMonitorV2Metric{RequestCount: 100, ErrorRequests: 20, SuccessRequests: 80, ErrorRate: 0.2, SuccessRate: 0.8} + ignored := int64(5) + counted := m.ErrorRequests - ignored + errorRate := float64(counted) / float64(m.RequestCount) + successRate := float64(m.SuccessRequests) / float64(m.RequestCount) + require.InDelta(t, 0.15, errorRate, 0.0001) + require.InDelta(t, 0.80, successRate, 0.0001) + require.Equal(t, int64(20), m.ErrorRequests) + require.Equal(t, int64(100), m.RequestCount) +} + +func TestErrorsForViewerStripsDetailsAndCountsForNonAdmin(t *testing.T) { + fixture := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{ + Items: []ChannelMonitorV2ErrorRow{{ + Category: "timeout", + Count: 42, + Rate: 0.4, + Details: []ChannelMonitorV2ErrorDetail{{ + Platform: "anthropic", + Model: "claude", + ErrorType: "upstream", + StatusCode: 504, + UpstreamStatusCode: 504, + Message: "gateway timeout sk-secret", + Count: 12, + }}, + }}, + } + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, errors: fixture} + svc := NewChannelMonitorV2Service(repo) + + userList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, false) + require.NoError(t, err) + require.False(t, repo.admin) + require.Len(t, userList.Items, 1) + require.Zero(t, userList.Items[0].Count) + require.Empty(t, userList.Items[0].Details) + require.InDelta(t, 0.4, userList.Items[0].Rate, 0.0001) + + adminList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, true) + require.NoError(t, err) + require.True(t, repo.admin) + require.Equal(t, int64(42), adminList.Items[0].Count) + require.Len(t, adminList.Items[0].Details, 1) + require.Contains(t, adminList.Items[0].Details[0].Message, "gateway timeout") +} + +func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) { + updatedBy := int64(9) + repo := &channelMonitorV2RepoStub{ + config: ChannelMonitorV2Config{Enabled: true}, + snap: &ChannelMonitorV2Snapshot{ + Config: ChannelMonitorV2Config{ + Version: 3, + Enabled: true, + RefreshIntervalSeconds: 300, + Platforms: []ChannelMonitorV2PlatformConfig{ + {Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}, + }, + GroupIDs: []int64{1, 2}, + IgnoredErrorCategories: []string{"timeout"}, + UpdatedBy: &updatedBy, + }, + Metrics: ChannelMonitorV2Metric{RequestCount: 100, ErrorRate: 0.1, SuccessRate: 0.9, RPM: 5}, + }, + } + svc := NewChannelMonitorV2Service(repo) + snap, err := svc.Snapshot(context.Background(), ChannelMonitorV2Filter{}, false) + require.NoError(t, err) + require.Empty(t, snap.Config.GroupIDs) + require.Empty(t, snap.Config.IgnoredErrorCategories) + require.Nil(t, snap.Config.UpdatedBy) + require.Empty(t, snap.Config.Platforms[0].Models) + require.Equal(t, 300, snap.Config.RefreshIntervalSeconds) + require.Zero(t, snap.Metrics.RequestCount) + require.InDelta(t, 0.1, snap.Metrics.ErrorRate, 0.0001) + require.Zero(t, snap.Metrics.RPM) +} + +func TestRedactChannelMonitorV2MetricKeepsRates(t *testing.T) { + p50 := int64(100) + m := ChannelMonitorV2Metric{ + SuccessRequests: 90, ErrorRequests: 10, RequestCount: 100, + InputTokens: 1, OutputTokens: 2, CacheCreationTokens: 3, CacheReadTokens: 4, + TokenCount: 10, RPM: 12, TPM: 34, ErrorRate: 0.1, SuccessRate: 0.9, + CacheRate: 0.5, CacheRateNumerator: 4, CacheRateDenominator: 8, + TTFT: ChannelMonitorV2Latency{SampleCount: 50, P50Ms: &p50}, + } + upstream := int64(3) + m.UpstreamAffectedRequests = &upstream + redactChannelMonitorV2Metric(&m, false) + require.Zero(t, m.RequestCount) + require.Zero(t, m.ErrorRequests) + require.Zero(t, m.TokenCount) + require.Zero(t, m.CacheRateNumerator) + require.Zero(t, m.TTFT.SampleCount) + require.Nil(t, m.UpstreamAffectedRequests) + require.InDelta(t, 0.1, m.ErrorRate, 0.0001) + require.InDelta(t, 12.0, m.RPM, 0.0001) + require.InDelta(t, 34.0, m.TPM, 0.0001) + require.NotNil(t, m.TTFT.P50Ms) + require.Equal(t, int64(100), *m.TTFT.P50Ms) + + redactChannelMonitorV2Metric(&m, true) + require.Zero(t, m.RPM) + require.Zero(t, m.TPM) + require.InDelta(t, 0.1, m.ErrorRate, 0.0001) +} + +func TestChannelMonitorV2HealthTTFTAtTargetIsHealthy(t *testing.T) { + p50 := int64(2500) + m := ChannelMonitorV2Metric{ + RequestCount: 100, ErrorRate: 0, + CacheRate: 1, CacheRateDenominator: 100, + TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50}, + } + h := ChannelMonitorV2HealthFor(m) + require.Equal(t, "healthy", h.TTFT) + require.NotNil(t, h.TTFTScore) + require.InDelta(t, 100.0, *h.TTFTScore, 0.01) +} diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go index 32704339d1..1e4dee2717 100644 --- a/backend/internal/service/channel_plaza.go +++ b/backend/internal/service/channel_plaza.go @@ -28,7 +28,8 @@ type PlazaModel struct { // PlazaGroup 模型广场中以分组为顶层的条目。 // // 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 -// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。 +// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 +// 具体平台),与「可用渠道」页口径一致。 type PlazaGroup struct { ID int64 Name string @@ -41,7 +42,11 @@ type PlazaGroup struct { PeakEnd string PeakRateMultiplier float64 IsExclusive bool - Models []PlazaModel + // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付 + // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 + ImageRateIndependent bool + ImageRateMultiplier float64 + Models []PlazaModel } // ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。 @@ -50,6 +55,8 @@ type PlazaGroup struct { // 平台隔离),仅把顶层从渠道换成分组: // - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定; // - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换; +// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价, +// 见 plazaImageDisplayPricing); // - 每个模型附带 LiteLLM 官方参考价(查不到为 nil); // - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称), // 组内模型按名称排序。 @@ -70,27 +77,35 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err }) 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] + 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, + 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) } - // modelIdx[groupID][modelName] = index into byGroup[groupID].Models - modelIdx := make(map[int64]map[string]int, len(groups)) + 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 { @@ -107,26 +122,32 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err } idx := modelIdx[gid] if idx == nil { - idx = make(map[string]int, len(supported)) + idx = make(map[modelKey]int, len(supported)) modelIdx[gid] = idx } for j := range supported { m := supported[j] - if m.Platform != pg.Platform { + if pg.Platform == PlatformComposite { + if !isConcreteRequestPlatform(m.Platform) { + continue + } + } else if m.Platform != pg.Platform { continue } - if at, seen := idx[m.Name]; seen { + 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 && m.Pricing != nil { - pg.Models[at].Pricing = m.Pricing + if pg.Models[at].Pricing == nil && pricing != nil { + pg.Models[at].Pricing = pricing } continue } - idx[m.Name] = len(pg.Models) + idx[key] = len(pg.Models) pg.Models = append(pg.Models, PlazaModel{ Name: m.Name, Platform: m.Platform, - Pricing: m.Pricing, + Pricing: pricing, }) } } @@ -139,7 +160,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err if len(pg.Models) == 0 { continue } - sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name }) + 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) } @@ -155,6 +181,53 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err 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 { diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/channel_plaza_test.go index fc9c7b7235..554654149c 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/channel_plaza_test.go @@ -107,6 +107,68 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) { require.Equal(t, "gpt-5", byName["g-gpt"][0].Name) } +func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2e-6 + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice}, + {Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice}, + {Platform: "", Models: []string{"empty-platform"}}, + {Platform: PlatformComposite, Models: []string{"nested-composite"}}, + {Platform: "unknown-platform", Models: []string{"unknown-platform"}}, + }, + } + groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}} + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 1) + require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct") + require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform) + require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform) + require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12) + require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12) +} + +func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) { + ch := Channel{ + ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)}, + {Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)}, + }, + } + groups := []Group{ + {ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1}, + {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}, + } + + out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + + require.NoError(t, err) + require.Len(t, out, 2) + byName := map[string]PlazaGroup{} + for _, group := range out { + byName[group.Name] = group + } + require.Len(t, byName["anthropic-only"].Models, 1) + require.Equal(t, []PlazaModel{{ + Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing, + }}, byName["anthropic-only"].Models) + require.Len(t, byName["composite"].Models, 2) + require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{ + byName["composite"].Models[0].Name, + byName["composite"].Models[1].Name, + }) + require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{ + byName["composite"].Models[0].Platform, + byName["composite"].Models[1].Platform, + }) +} + func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) { inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet") inactive.Status = "inactive" @@ -173,6 +235,80 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) { require.Nil(t, byName["token-absent"].OfficialPricing) } +func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { + // 图片计费模型:档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价), + // 分组独立倍率字段透传;未配图片价的分组保持渠道定价原样。 + perReq := 0.2 + tier4K := 0.3 + imgPrice := 0.02 + channels := []Channel{{ + ID: 1, Name: "img-ch", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{{ + Platform: "openai", + Models: []string{"gpt-image-2"}, + BillingMode: BillingModeImage, + PerRequestPrice: &perReq, + Intervals: []PricingInterval{{TierLabel: "4K", PerRequestPrice: &tier4K}}, + }}, + }} + groups := []Group{ + {ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1, + 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()) + require.NoError(t, err) + require.Len(t, out, 2) + byName := map[string]PlazaGroup{} + for _, g := range out { + byName[g.Name] = g + } + + media := byName["g-media"] + require.True(t, media.ImageRateIndependent) + require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9) + require.Len(t, media.Models, 1) + p := media.Models[0].Pricing + require.NotNil(t, p) + require.Len(t, p.Intervals, 3) + tierPrices := map[string]float64{} + for _, iv := range p.Intervals { + require.NotNil(t, iv.PerRequestPrice) + tierPrices[iv.TierLabel] = *iv.PerRequestPrice + } + require.InDelta(t, 0.02, tierPrices["1K"], 1e-9, "1K 用分组图片价") + require.InDelta(t, 0.2, tierPrices["2K"], 1e-9, "2K 分组未配,回落渠道默认按次价") + require.InDelta(t, 0.3, tierPrices["4K"], 1e-9, "4K 分组未配,回落渠道档位价") + + plain := byName["g-plain"] + require.False(t, plain.ImageRateIndependent) + require.Len(t, plain.Models, 1) + pp := plain.Models[0].Pricing + require.NotNil(t, pp) + require.Len(t, pp.Intervals, 1, "未配分组图片价:渠道定价原样") + require.InDelta(t, 0.2, *pp.PerRequestPrice, 1e-9) + + // 合成为克隆,渠道原始定价不被修改 + require.Len(t, channels[0].ModelPricing[0].Intervals, 1) +} + +func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) { + // token 模式定价不受分组图片价影响。 + 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()) + require.NoError(t, err) + require.Len(t, out, 1) + p := out[0].Models[0].Pricing + require.NotNil(t, p) + require.Empty(t, p.Intervals) + require.NotNil(t, p.InputPrice) + require.Nil(t, p.PerRequestPrice) +} + func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) { sentinel := errors.New("boom") repo := &mockChannelRepository{ diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 934f06d976..e56f699a8e 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -108,7 +108,7 @@ type ChannelMappingResult struct { MappedModel string // 映射后的模型名(无映射时等于原始模型名) ChannelID int64 // 渠道 ID(0 = 无渠道关联) Mapped bool // 是否发生了映射 - BillingModelSource string // 计费模型来源("requested" / "upstream" / "channel_mapped") + BillingModelSource string // 计费模型来源("requested" / "upstream" / "channel_mapped" / "response_model") } // BuildModelMappingChain 根据映射结果和上游实际模型构建映射链描述。 diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index 923f30b7cc..c37d444370 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -48,9 +48,14 @@ var claudeCodeSystemPrompts = []string{ } const ( + // These markers identify Claude Code's official security-monitor classifier + // request without coupling validation to every wording change in the prompt. + claudeCodeSecurityMonitorPromptPrefix = "You are a security monitor for autonomous AI coding agents." + claudeCodeSecurityMonitorPromptMinLen = 10_000 + // claudeCodeBillingHeaderPrefix 是 Claude Code 在 system 数组首块注入的计费归因块前缀。 - // 该块存在于所有真实 Claude Code CLI 请求中(含安全监视器等无身份 prose 的子请求), - // 格式固定、不随提示词改版漂移,是比身份 prose 更稳定的客户端标识。 + // 大多数真实 CLI 请求(含部分无身份 prose 的子请求)会携带该块;不携带该块的 + // 固定官方辅助请求由独立规则识别。该格式比身份 prose 更稳定。 // 生成见 gateway_billing_block.go;同类识别见 pkg/apicompat/anthropic_to_responses.go。 claudeCodeBillingHeaderPrefix = "x-anthropic-billing-header" // claudeCodeEntrypointMarker 标识计费块携带入口归因字段。不绑定具体入口值 @@ -167,6 +172,10 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } + if isClaudeCodeSecurityMonitorPrompt(systemEntries) { + return true + } + // 检查每个 system entry for _, entry := range systemEntries { entryMap, ok := entry.(map[string]any) @@ -196,6 +205,57 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } +// claudeCodeSecurityMonitorMarkers 与固定前缀、长度下限共同构成分类器提示词的 +// 判别条件,须全部命中。 +var claudeCodeSecurityMonitorMarkers = []string{ + "## Threat Model", + "- ``:", + "## HARD BLOCK", + "## SOFT BLOCK", + "## Classification Process", + "## Output Format", + "yes", + "no", +} + +// isClaudeCodeSecurityMonitorPrompt 识别 Claude Code auto 模式安全监视器分类器请求。 +// 真实 CLI(实测 2.1.220)会在监视器提示词之外追加独立的会话上下文 system 块, +// entry 数量不受服务端控制,故逐 entry 查找匹配项而非限定恰好一个 entry。 +func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool { + for _, raw := range systemEntries { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + + entryType, ok := entry["type"].(string) + if !ok || entryType != "text" { + continue + } + + text, ok := entry["text"].(string) + if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen || + !strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) { + continue + } + + if hasAllClaudeCodeSecurityMonitorMarkers(text) { + return true + } + } + + return false +} + +func hasAllClaudeCodeSecurityMonitorMarkers(text string) bool { + for _, marker := range claudeCodeSecurityMonitorMarkers { + if !strings.Contains(text, marker) { + return false + } + } + return true +} + // bestSimilarityScore 计算文本与所有 Claude Code 模板的最佳相似度 func (v *ClaudeCodeValidator) bestSimilarityScore(text string) float64 { normalizedText := normalizePrompt(text) diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index d54177d132..bf0e127e82 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" @@ -135,6 +136,197 @@ func TestClaudeCodeValidator_BillingBlockRecognizedWithoutIdentityPrompt(t *test require.True(t, ok) } +func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) { + monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt") + require.NoError(t, err) + + validHeaders := map[string]string{ + "User-Agent": "claude-cli/2.1.220 (external, cli)", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + } + validBody := func(prompt string) map[string]any { + return map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{ + map[string]any{"type": "text", "text": prompt}, + }, + "metadata": map[string]any{"user_id": claudeCodeMetadataUserIDJSON}, + } + } + + // 真实 CLI(2.1.220)在监视器提示词之后追加的独立会话上下文块(脱敏), + // 随会话/环境变化,服务端不可控(见 issue #5152 抓包)。 + sessionContext := "\n\n## Session Context\n\n- **User identity**: testuser\n" + + "- **Working directory**: /home/testuser/project\n- **Platform**: linux" + + tests := []struct { + name string + headers map[string]string + body map[string]any + wantAccept bool + }{ + { + name: "official classifier request", + headers: validHeaders, + body: validBody(string(monitorPrompt)), + wantAccept: true, + }, + { + name: "classifier output with category element", + headers: validHeaders, + body: validBody(strings.Replace( + string(monitorPrompt), + "yes", + "yesExact BLOCK Rule Name", + 1, + )), + wantAccept: true, + }, + { + name: "non-Claude user agent", + headers: map[string]string{ + "User-Agent": "curl/8.0.0", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing X-App", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "anthropic-beta": validHeaders["anthropic-beta"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-beta", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-version", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-beta": validHeaders["anthropic-beta"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing metadata", + headers: validHeaders, + body: map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{map[string]any{"type": "text", "text": string(monitorPrompt)}}, + }, + }, + { + name: "invalid metadata user ID", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + body["metadata"] = map[string]any{"user_id": "invalid"} + return body + }(), + }, + { + name: "unrelated prompt", + headers: validHeaders, + body: validBody("You are a different security classifier for coding agents."), + wantAccept: false, + }, + { + name: "opening sentence alone", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix), + wantAccept: false, + }, + { + name: "opening sentence plus arbitrary altered suffix", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix + "\n\n" + + strings.Repeat("This is arbitrary altered classifier content. ", 300)), + wantAccept: false, + }, + { + // 回归 issue #5152:真实分类器请求携带 2 个 system entry + //(监视器提示词 + 追加的会话上下文块),不得因 entry 数量拒识。 + name: "classifier with trailing session context entry", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + system, ok := body["system"].([]any) + require.True(t, ok) + body["system"] = append(system, map[string]any{ + "type": "text", + "text": sessionContext, + }) + return body + }(), + wantAccept: true, + }, + { + name: "classifier with leading session context entry", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + system, ok := body["system"].([]any) + require.True(t, ok) + body["system"] = append([]any{map[string]any{ + "type": "text", + "text": sessionContext, + }}, system...) + return body + }(), + wantAccept: true, + }, + { + name: "session context entry alone", + headers: validHeaders, + body: validBody(sessionContext), + wantAccept: false, + }, + { + // 篡改后的长提示词(marker 缺失)即便带上会话上下文块也不得放行。 + name: "tampered classifier with session context entry", + headers: validHeaders, + body: func() map[string]any { + body := validBody(strings.ReplaceAll( + string(monitorPrompt), "## HARD BLOCK", "## ALTERED BLOCK")) + system, ok := body["system"].([]any) + require.True(t, ok) + body["system"] = append(system, map[string]any{ + "type": "text", + "text": sessionContext, + }) + return body + }(), + wantAccept: false, + }, + } + + validator := NewClaudeCodeValidator() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) + for name, value := range tt.headers { + req.Header.Set(name, value) + } + + require.Equal(t, tt.wantAccept, validator.Validate(req, tt.body)) + }) + } +} + func TestClaudeCodeValidator_BillingBlockVSCodeEntrypointRecognized(t *testing.T) { // 回归:Claude Code 在 VSCode 扩展内运行时,计费块入口为 cc_entrypoint=claude-vscode // 而非 cli。其安全监视器子请求同样不携带身份 prose,此前写死 cc_entrypoint=cli 的 diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index 62f12eeab7..acf38bf49b 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -21,6 +21,7 @@ import ( "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" ) @@ -138,10 +139,12 @@ func ContentModerationCategories() []string { } type ContentModerationConfig struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` - BaseURL string `json:"base_url"` - Model string `json:"model"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + // ProxyID 指定审计请求使用的代理服务器(IP管理-代理服务器),nil 表示直连。 + ProxyID *int64 `json:"proxy_id,omitempty"` APIKey string `json:"api_key,omitempty"` APIKeys []string `json:"api_keys,omitempty"` TimeoutMS int `json:"timeout_ms"` @@ -176,6 +179,7 @@ type ContentModerationConfigView struct { Mode string `json:"mode"` BaseURL string `json:"base_url"` Model string `json:"model"` + ProxyID *int64 `json:"proxy_id"` APIKeyConfigured bool `json:"api_key_configured"` APIKeyMasked string `json:"api_key_masked"` APIKeyCount int `json:"api_key_count"` @@ -240,8 +244,10 @@ type TestContentModerationAPIKeysInput struct { BaseURL string `json:"base_url"` Model string `json:"model"` TimeoutMS int `json:"timeout_ms"` - Prompt string `json:"prompt"` - Images []string `json:"images"` + // ProxyID nil 表示沿用已保存配置的代理;<=0 表示强制直连测试;>0 表示指定代理测试。 + ProxyID *int64 `json:"proxy_id"` + Prompt string `json:"prompt"` + Images []string `json:"images"` } type TestContentModerationAPIKeysResult struct { @@ -260,10 +266,12 @@ type ContentModerationTestAuditResult struct { } type UpdateContentModerationConfigInput struct { - Enabled *bool `json:"enabled"` - Mode *string `json:"mode"` - BaseURL *string `json:"base_url"` - Model *string `json:"model"` + Enabled *bool `json:"enabled"` + Mode *string `json:"mode"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + // ProxyID nil 表示不修改;<=0 表示清除代理(恢复直连);>0 表示指定代理。 + ProxyID *int64 `json:"proxy_id"` APIKey *string `json:"api_key"` APIKeys *[]string `json:"api_keys"` APIKeysMode string `json:"api_keys_mode"` @@ -495,9 +503,11 @@ type ContentModerationService struct { hashCache ContentModerationHashCache groupRepo GroupRepository userRepo UserRepository + proxyRepo ProxyRepository authCacheInvalidator APIKeyAuthCacheInvalidator emailService *EmailService httpClient *http.Client + moderationProxyCache atomic.Pointer[moderationProxyURLCacheEntry] asyncQueue chan contentModerationTask workerCount int apiKeyCursor atomic.Uint64 @@ -566,6 +576,7 @@ func NewContentModerationService( hashCache ContentModerationHashCache, groupRepo GroupRepository, userRepo UserRepository, + proxyRepo ProxyRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, emailService *EmailService, ) *ContentModerationService { @@ -575,6 +586,7 @@ func NewContentModerationService( hashCache: hashCache, groupRepo: groupRepo, userRepo: userRepo, + proxyRepo: proxyRepo, authCacheInvalidator: authCacheInvalidator, emailService: emailService, httpClient: servertiming.InstrumentClient(nil), @@ -616,6 +628,14 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat if input.Model != nil { cfg.Model = strings.TrimSpace(*input.Model) } + if input.ProxyID != nil { + if *input.ProxyID > 0 { + id := *input.ProxyID + cfg.ProxyID = &id + } else { + cfg.ProxyID = nil + } + } if input.TimeoutMS != nil { cfg.TimeoutMS = *input.TimeoutMS } @@ -716,6 +736,8 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat return nil, fmt.Errorf("save content moderation config: %w", err) } s.replaceRuntimeConfig(cfg, raw) + // 代理选择可能已变化,丢弃已解析的代理 URL 缓存,下次调用即时生效。 + s.moderationProxyCache.Store(nil) return s.configView(cfg), nil } @@ -739,6 +761,14 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo if input.TimeoutMS > 0 { cfg.TimeoutMS = input.TimeoutMS } + if input.ProxyID != nil { + if *input.ProxyID > 0 { + id := *input.ProxyID + cfg.ProxyID = &id + } else { + cfg.ProxyID = nil + } + } cfg.normalize() testInput, imageCount, err := buildModerationTestInput(input.Prompt, input.Images) if err != nil { @@ -1631,6 +1661,11 @@ func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *Cont if _, err := url.ParseRequestURI(cfg.BaseURL); err != nil { return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BASE_URL", "OpenAI Base URL 无效") } + if cfg.ProxyID != nil && s.proxyRepo != nil { + if _, err := s.proxyRepo.GetByID(ctx, *cfg.ProxyID); err != nil { + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_PROXY", fmt.Sprintf("代理服务器不存在: %d", *cfg.ProxyID)) + } + } if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 { return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间") } @@ -1723,9 +1758,9 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") - client := s.httpClient - if client == nil { - client = http.DefaultClient + client, err := s.moderationHTTPClient(ctx, cfg) + if err != nil { + return nil, err } resp, err := client.Do(req) if err != nil { @@ -1750,6 +1785,73 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte return &out.Results[0], nil } +// moderationProxyURLCacheEntry 缓存 proxy_id 到代理 URL 的解析结果, +// 避免审计热路径上每次调用都查询数据库。 +type moderationProxyURLCacheEntry struct { + proxyID int64 + url string + expiresAt time.Time +} + +const contentModerationProxyURLCacheTTL = time.Minute + +// moderationHTTPClient 返回本次审计调用应使用的 HTTP 客户端。 +// 未配置代理时沿用默认客户端;配置了代理时通过共享客户端池构建, +// 代理解析/构建失败直接返回错误,绝不回退直连(避免 IP 关联风险)。 +func (s *ContentModerationService) moderationHTTPClient(ctx context.Context, cfg *ContentModerationConfig) (*http.Client, error) { + if cfg == nil || cfg.ProxyID == nil { + if s.httpClient == nil { + return http.DefaultClient, nil + } + return s.httpClient, nil + } + proxyURL, err := s.resolveModerationProxyURL(ctx, *cfg.ProxyID) + if err != nil { + return nil, err + } + client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxyURL}) + if err != nil { + return nil, fmt.Errorf("build moderation proxy client: %w", err) + } + return client, nil +} + +func (s *ContentModerationService) resolveModerationProxyURL(ctx context.Context, proxyID int64) (string, error) { + now := time.Now() + prev := s.moderationProxyCache.Load() + if prev != nil && prev.proxyID == proxyID && now.Before(prev.expiresAt) { + return prev.url, nil + } + if s.proxyRepo == nil { + return "", errors.New("moderation proxy repository unavailable") + } + px, err := s.proxyRepo.GetByID(ctx, proxyID) + if err != nil { + return "", fmt.Errorf("resolve moderation proxy %d: %w", proxyID, err) + } + if !px.IsActive() || px.IsExpired(now) { + slog.Warn("content_moderation.proxy_not_active", + "proxy_id", proxyID, + "proxy_name", px.Name, + "status", px.Status, + "expired", px.IsExpired(now)) + } + proxyURL := px.URL() + if prev == nil || prev.proxyID != proxyID || prev.url != proxyURL { + // 不打印完整 URL(可能含认证信息),仅记录可定位的地址。 + slog.Info("content_moderation.proxy_enabled", + "proxy_id", proxyID, + "proxy_name", px.Name, + "proxy_addr", fmt.Sprintf("%s://%s:%d", px.Protocol, px.Host, px.Port)) + } + s.moderationProxyCache.Store(&moderationProxyURLCacheEntry{ + proxyID: proxyID, + url: proxyURL, + expiresAt: now.Add(contentModerationProxyURLCacheTTL), + }) + return proxyURL, nil +} + func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, flagged bool, highestCategory string, highestScore float64, scores map[string]float64, text string, latency *int, queueDelay *int, errText string) *ContentModerationLog { var userID *int64 if input.UserID > 0 { @@ -2013,6 +2115,7 @@ func cloneContentModerationConfig(cfg *ContentModerationConfig) *ContentModerati return nil } clone := *cfg + clone.ProxyID = cloneInt64Ptr(cfg.ProxyID) clone.APIKeys = append([]string(nil), cfg.APIKeys...) clone.GroupIDs = append([]int64(nil), cfg.GroupIDs...) clone.BlockedKeywords = append([]string(nil), cfg.BlockedKeywords...) @@ -2042,6 +2145,9 @@ func (cfg *ContentModerationConfig) normalize() { cfg.Model = defaultContentModerationModel } cfg.Model = strings.TrimSpace(cfg.Model) + if cfg.ProxyID != nil && *cfg.ProxyID <= 0 { + cfg.ProxyID = nil + } if cfg.TimeoutMS <= 0 { cfg.TimeoutMS = defaultContentModerationTimeoutMS } @@ -2305,6 +2411,7 @@ func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *Con Mode: cfg.Mode, BaseURL: cfg.BaseURL, Model: cfg.Model, + ProxyID: cloneInt64Ptr(cfg.ProxyID), APIKeyConfigured: len(keys) > 0, APIKeyMasked: apiKeyMasked, APIKeyCount: len(keys), diff --git a/backend/internal/service/content_moderation_cyber_test.go b/backend/internal/service/content_moderation_cyber_test.go index 07ef2812d3..3574b2b8f1 100644 --- a/backend/internal/service/content_moderation_cyber_test.go +++ b/backend/internal/service/content_moderation_cyber_test.go @@ -76,6 +76,7 @@ func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) { nil, nil, nil, + nil, ) svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ @@ -102,6 +103,7 @@ func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) { nil, nil, nil, + nil, nil, // emailService=nil: email path safely skipped ) @@ -179,6 +181,7 @@ func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) { nil, nil, nil, + nil, nil, // emailService=nil: email path safely skipped; see doc comment above ) @@ -233,7 +236,7 @@ func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) { repo := &banCountArgsTestRepo{} svc := NewContentModerationService( &contentModerationTestSettingRepo{values: map[string]string{}}, - repo, nil, nil, nil, nil, nil, + repo, nil, nil, nil, nil, nil, nil, ) userID := int64(42) @@ -255,7 +258,7 @@ func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing. SettingKeyRiskControlEnabled: "true", SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`, }}, - repo, nil, nil, nil, nil, nil, + repo, nil, nil, nil, nil, nil, nil, ) svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ @@ -282,7 +285,7 @@ func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) { &contentModerationTestSettingRepo{values: map[string]string{ SettingKeyRiskControlEnabled: "true", }}, - repo, nil, nil, nil, nil, nil, + repo, nil, nil, nil, nil, nil, nil, ) svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ diff --git a/backend/internal/service/content_moderation_proxy_test.go b/backend/internal/service/content_moderation_proxy_test.go new file mode 100644 index 0000000000..0778530fcd --- /dev/null +++ b/backend/internal/service/content_moderation_proxy_test.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// contentModerationTestProxyRepo 仅实现审计代理路径用到的 GetByID,其余方法不应被调用。 +type contentModerationTestProxyRepo struct { + proxies map[int64]*Proxy + getByIDErr error + getCalls atomic.Int64 +} + +func (r *contentModerationTestProxyRepo) GetByID(ctx context.Context, id int64) (*Proxy, error) { + r.getCalls.Add(1) + if r.getByIDErr != nil { + return nil, r.getByIDErr + } + if px, ok := r.proxies[id]; ok { + return px, nil + } + return nil, errors.New("proxy not found") +} + +func (r *contentModerationTestProxyRepo) Create(ctx context.Context, proxy *Proxy) error { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) Update(ctx context.Context, proxy *Proxy) error { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) Delete(ctx context.Context, id int64) error { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListWithFiltersAndAccountCount(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListActive(ctx context.Context) ([]Proxy, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) SweepExpiredProxies(ctx context.Context, now time.Time) (int64, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) ListAllForFallback(ctx context.Context) ([]Proxy, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) CountExpired(ctx context.Context) (int64, error) { + panic("not implemented") +} + +func (r *contentModerationTestProxyRepo) CountExpiringSoon(ctx context.Context, now time.Time) (int64, error) { + panic("not implemented") +} + +func moderationProxyIDPtr(v int64) *int64 { return &v } + +// 审计请求必须真正经过配置的代理发出(#2646 核心行为)。 +// 通过一个本地 HTTP 正向代理验证:BaseURL 指向不可直连的假域名, +// 请求只有走代理才能得到响应。 +func TestContentModerationCallRoutesThroughProxy(t *testing.T) { + var proxied atomic.Int64 + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // HTTP 目标经正向代理时,代理收到的是绝对 URI 请求。 + if !strings.HasPrefix(r.RequestURI, "http://moderation-proxy-test.invalid") { + t.Errorf("expected absolute-URI proxy request, got %q", r.RequestURI) + } + proxied.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}}) + })) + defer proxySrv.Close() + + proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://") + host, portStr, ok := strings.Cut(proxyAddr, ":") + if !ok { + t.Fatalf("unexpected proxy addr: %s", proxyAddr) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("parse proxy port: %v", err) + } + + proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{ + 7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive}, + }} + svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil) + + cfg := defaultContentModerationConfig() + cfg.BaseURL = "http://moderation-proxy-test.invalid" + cfg.ProxyID = moderationProxyIDPtr(7) + cfg.normalize() + + httpStatus := 0 + if _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus); err != nil { + t.Fatalf("expected moderation call via proxy to succeed, got: %v", err) + } + if proxied.Load() == 0 { + t.Fatal("expected request to be routed through the proxy server") + } +} + +// 代理解析失败必须报错,而不是静默回退直连。 +func TestContentModerationProxyResolveFailureDoesNotFallBackToDirect(t *testing.T) { + var direct atomic.Int64 + directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + direct.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}}) + })) + defer directSrv.Close() + + proxyRepo := &contentModerationTestProxyRepo{getByIDErr: errors.New("proxy deleted")} + svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil) + + cfg := defaultContentModerationConfig() + cfg.BaseURL = directSrv.URL + cfg.ProxyID = moderationProxyIDPtr(9) + cfg.normalize() + + httpStatus := 0 + _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus) + if err == nil || !strings.Contains(err.Error(), "resolve moderation proxy") { + t.Fatalf("expected proxy resolve error, got: %v", err) + } + if direct.Load() != 0 { + t.Fatal("must not fall back to direct connection when proxy resolution fails") + } +} + +// 代理 URL 解析结果按 TTL 缓存,热路径不应每次调用都查库。 +func TestContentModerationProxyURLResolutionCached(t *testing.T) { + proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{ + 3: {ID: 3, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive}, + }} + svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil) + + for i := 0; i < 5; i++ { + if _, err := svc.resolveModerationProxyURL(context.Background(), 3); err != nil { + t.Fatalf("resolve attempt %d failed: %v", i, err) + } + } + if got := proxyRepo.getCalls.Load(); got != 1 { + t.Fatalf("expected exactly 1 repository lookup thanks to caching, got %d", got) + } +} + +// UpdateConfig 的 proxy_id 语义:>0 设置、nil 保持、<=0 清除;配置视图回显。 +func TestContentModerationUpdateConfigProxyIDSemantics(t *testing.T) { + settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}} + proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{ + 5: {ID: 5, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive}, + }} + svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil) + ctx := context.Background() + + view, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(5)}) + if err != nil { + t.Fatalf("set proxy_id=5: %v", err) + } + if view.ProxyID == nil || *view.ProxyID != 5 { + t.Fatalf("expected proxy_id=5 in view, got %v", view.ProxyID) + } + + // nil 表示不修改,代理保持不变。 + enabled := true + view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{Enabled: &enabled}) + if err != nil { + t.Fatalf("update unrelated field: %v", err) + } + if view.ProxyID == nil || *view.ProxyID != 5 { + t.Fatalf("expected proxy_id to stay 5 when omitted, got %v", view.ProxyID) + } + + // 0 表示清除,恢复直连。 + view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(0)}) + if err != nil { + t.Fatalf("clear proxy_id: %v", err) + } + if view.ProxyID != nil { + t.Fatalf("expected proxy_id cleared, got %v", *view.ProxyID) + } + + // 不存在的代理必须被校验拒绝。 + if _, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(404)}); err == nil { + t.Fatal("expected validation error for nonexistent proxy") + } +} + +// TestAPIKeys 的 proxy_id 语义:nil 沿用已保存配置的代理;0 强制直连;>0 指定代理。 +func TestContentModerationTestAPIKeysProxySemantics(t *testing.T) { + var proxied atomic.Int64 + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}}) + })) + defer proxySrv.Close() + proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://") + host, portStr, _ := strings.Cut(proxyAddr, ":") + port, _ := strconv.Atoi(portStr) + + savedCfg := defaultContentModerationConfig() + savedCfg.BaseURL = "http://moderation-proxy-test.invalid" + savedCfg.ProxyID = moderationProxyIDPtr(7) + savedCfg.APIKeys = []string{"sk-saved"} + rawCfg, err := json.Marshal(savedCfg) + if err != nil { + t.Fatalf("marshal cfg: %v", err) + } + + settingRepo := &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyContentModerationConfig: string(rawCfg), + }} + proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{ + 7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive}, + }} + svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil) + + // nil:沿用已保存配置的代理,测试请求应经过代理成功。 + result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{APIKeys: []string{"sk-input"}}) + if err != nil { + t.Fatalf("test with saved proxy: %v", err) + } + if len(result.Items) != 1 || result.Items[0].Status == "error" { + t.Fatalf("expected key test via proxy to succeed, got %+v", result.Items) + } + if proxied.Load() == 0 { + t.Fatal("expected test request to route through the saved proxy") + } + + // 0:强制直连;BaseURL 指向本地可直连服务器,应成功且不再经过代理。 + var direct atomic.Int64 + directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + direct.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}}) + })) + defer directSrv.Close() + + before := proxied.Load() + result, err = svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{ + APIKeys: []string{"sk-input"}, + BaseURL: directSrv.URL, + ProxyID: moderationProxyIDPtr(0), + }) + if err != nil { + t.Fatalf("test with forced direct: %v", err) + } + if len(result.Items) != 1 || result.Items[0].Status == "error" { + t.Fatalf("expected forced-direct test to succeed, got %+v", result.Items) + } + if direct.Load() == 0 { + t.Fatal("expected forced-direct test to reach the base URL directly") + } + if proxied.Load() != before { + t.Fatal("forced-direct test must not route through the proxy") + } +} diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index 76f50248b1..1cacc67477 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -486,6 +486,7 @@ func TestContentModerationCheck_PreBlockKeywordHitSkipsUpstreamCall(t *testing.T nil, nil, nil, + nil, ) body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`) @@ -536,6 +537,7 @@ func TestContentModerationCheck_KeywordsIgnoredInObserveMode(t *testing.T) { nil, nil, nil, + nil, ) body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`) @@ -581,6 +583,7 @@ func TestContentModerationCheck_KeywordOnlyStrategySkipsAPIOnMiss(t *testing.T) nil, nil, nil, + nil, ) body := []byte(`{"messages":[{"role":"user","content":"absolutely clean prompt"}]}`) @@ -627,6 +630,7 @@ func TestContentModerationCheck_APIOnlyStrategyIgnoresKeywordList(t *testing.T) nil, nil, nil, + nil, ) body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`) @@ -734,6 +738,7 @@ func TestContentModerationLoadConfig_LegacyConfigDefaultsModelFilterToAll(t *tes nil, nil, nil, + nil, ) cfg, err := svc.loadConfig(context.Background()) @@ -787,6 +792,7 @@ func newContentModerationModelFilterTestService(t *testing.T, cfg *ContentModera nil, nil, nil, + nil, ) return svc, repo } @@ -800,7 +806,7 @@ func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) { repo := &contentModerationTestSettingRepo{values: map[string]string{ SettingKeyContentModerationConfig: string(rawCfg), }} - svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil) deleteHashes := []string{moderationAPIKeyHash("sk-old-a")} addKeys := []string{"sk-new-c", "sk-old-b"} @@ -827,7 +833,7 @@ func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T repo := &contentModerationTestSettingRepo{values: map[string]string{ SettingKeyContentModerationConfig: string(rawCfg), }} - svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil) deleteHashes := []string{moderationAPIKeyHash("sk-old-a")} replaceKeys := []string{"sk-new-only"} @@ -854,7 +860,7 @@ func TestContentModerationUpdateConfig_SavesCustomThresholds(t *testing.T) { repo := &contentModerationTestSettingRepo{values: map[string]string{ SettingKeyContentModerationConfig: string(rawCfg), }} - svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil) thresholds := map[string]float64{ "sexual": 0.72, "harassment": 1.25, @@ -1038,6 +1044,7 @@ func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *t nil, nil, nil, + nil, ) body := []byte(`{ @@ -1102,6 +1109,7 @@ func TestContentModerationCheck_PreBlockBlocksCodexResponsesLatestUserInput(t *t nil, nil, nil, + nil, ) body := []byte(`{ @@ -1171,6 +1179,7 @@ func TestContentModerationStatusTracksPreBlockSyncMetrics(t *testing.T) { nil, nil, nil, + nil, ) for _, prompt := range []string{"blocked prompt", "clean prompt"} { @@ -1221,6 +1230,7 @@ func TestContentModerationStatusTracksPreBlockAPIKeyLoad(t *testing.T) { nil, nil, nil, + nil, ) for idx := 0; idx < 4; idx++ { @@ -1266,6 +1276,7 @@ func TestContentModerationStatusTracksPreBlockLocalBlocks(t *testing.T) { nil, nil, nil, + nil, ) for _, prompt := range []string{"blocked prompt", "clean prompt"} { @@ -1314,7 +1325,7 @@ func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) { cfg.BaseURL = server.URL cfg.APIKeys = []string{"sk-test"} cfg.RetryCount = 5 - svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil) _, err := svc.callModeration(context.Background(), cfg, "hello") @@ -1353,7 +1364,7 @@ func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) { cfg.BaseURL = server.URL cfg.APIKeys = []string{"sk-test"} cfg.RetryCount = 0 - svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil) _, err := svc.callModeration(context.Background(), cfg, "hello") @@ -1385,6 +1396,7 @@ func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) { nil, nil, nil, + nil, ) result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{ APIKeys: []string{"sk-test"}, @@ -1430,6 +1442,7 @@ func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) { userRepo, nil, nil, + nil, ) decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ @@ -1497,6 +1510,7 @@ func TestContentModerationCheck_HashBlockLogsDoNotIncreaseNextViolationCount(t * nil, nil, nil, + nil, ) decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ @@ -1530,7 +1544,7 @@ func TestContentModerationAutoBanSkipsAdminAccount(t *testing.T) { require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID))) userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}} invalidator := &contentModerationTestAuthCacheInvalidator{} - svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil) svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true) @@ -1557,7 +1571,7 @@ func TestContentModerationAutoBanDisablesRegularUserAtThreshold(t *testing.T) { require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID))) userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleUser, Status: StatusActive}} invalidator := &contentModerationTestAuthCacheInvalidator{} - svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil) svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true) @@ -1578,7 +1592,7 @@ func TestContentModerationAdminBelowBanThresholdRecordsViolationOnly(t *testing. repo := &contentModerationTestRepo{} userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}} invalidator := &contentModerationTestAuthCacheInvalidator{} - svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil) svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true) @@ -1637,6 +1651,7 @@ func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T nil, nil, nil, + nil, ) body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`) @@ -1750,6 +1765,7 @@ func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) { nil, nil, nil, + nil, ) decision := svc.checkSync(context.Background(), ContentModerationCheckInput{ @@ -1787,7 +1803,7 @@ func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *test userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}} invalidator := &contentModerationTestAuthCacheInvalidator{} repo := &contentModerationTestRepo{} - svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil) result, err := svc.UnbanUser(context.Background(), 1001) @@ -1803,7 +1819,7 @@ func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testin userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}} invalidator := &contentModerationTestAuthCacheInvalidator{} repo := &contentModerationTestRepo{} - svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil) result, err := svc.UnbanUser(context.Background(), 1001) @@ -1819,7 +1835,7 @@ func contentModerationIntPtr(v int) *int { func TestContentModerationUpdateConfig_CyberPolicyExcludeFromBanCount(t *testing.T) { settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}} - svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil) + svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil, nil) // 默认值必须是 false(计入,保持现状) view, err := svc.GetConfig(context.Background()) diff --git a/backend/internal/service/credentials_sanitize.go b/backend/internal/service/credentials_sanitize.go new file mode 100644 index 0000000000..fb1b500e9d --- /dev/null +++ b/backend/internal/service/credentials_sanitize.go @@ -0,0 +1,21 @@ +package service + +// SanitizeStoredCredentials strips secrets that must never be persisted on the +// account credentials map after conversion to OAuth tokens (Grok Web SSO / password). +// Call from admin create/update/import/apply-oauth paths. +// +// Cookie is always stripped: bulk paths may pass an empty platform label, and +// session-jar residue must never sit next to OAuth tokens on any platform. +// The platform argument is retained for call-site clarity / future scrubbing. +func SanitizeStoredCredentials(platform string, creds map[string]any) map[string]any { + if creds == nil { + return nil + } + _ = platform + for _, key := range []string{ + "password", "sso_token", "sso", "sso-rw", "clearTextPassword", "cookie", + } { + delete(creds, key) + } + return creds +} diff --git a/backend/internal/service/credentials_sanitize_test.go b/backend/internal/service/credentials_sanitize_test.go new file mode 100644 index 0000000000..c704102d5f --- /dev/null +++ b/backend/internal/service/credentials_sanitize_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeStoredCredentials_StripsEphemeralSSOSecrets(t *testing.T) { + creds := map[string]any{ + "access_token": "at", + "refresh_token": "rt", + "password": "secret", + "sso_token": "sso", + "sso": "cookie-sso", + "sso-rw": "rw", + "clearTextPassword": "plain", + "cookie": "jar", + "base_url": "https://api.x.ai", + } + out := SanitizeStoredCredentials(PlatformGrok, creds) + require.Equal(t, "at", out["access_token"]) + require.Equal(t, "rt", out["refresh_token"]) + require.Equal(t, "https://api.x.ai", out["base_url"]) + require.NotContains(t, out, "password") + require.NotContains(t, out, "sso_token") + require.NotContains(t, out, "sso") + require.NotContains(t, out, "sso-rw") + require.NotContains(t, out, "clearTextPassword") + require.NotContains(t, out, "cookie") +} + +func TestSanitizeStoredCredentials_AlwaysStripsCookie(t *testing.T) { + // Bulk paths may pass empty platform; cookie must never persist next to tokens. + for _, platform := range []string{PlatformOpenAI, PlatformGrok, ""} { + creds := map[string]any{ + "cookie": "session", + "password": "x", + "api_key": "k", + } + out := SanitizeStoredCredentials(platform, creds) + require.Equal(t, "k", out["api_key"], platform) + require.NotContains(t, out, "password", platform) + require.NotContains(t, out, "cookie", platform) + } +} + +func TestSanitizeStoredCredentials_NilSafe(t *testing.T) { + require.Nil(t, SanitizeStoredCredentials(PlatformGrok, nil)) +} diff --git a/backend/internal/service/crs_sync_helpers_test.go b/backend/internal/service/crs_sync_helpers_test.go index bd6434c1f5..effeca4677 100644 --- a/backend/internal/service/crs_sync_helpers_test.go +++ b/backend/internal/service/crs_sync_helpers_test.go @@ -115,15 +115,17 @@ func TestShouldCreateAccount(t *testing.T) { func TestReconcileCRSUpstreamBillingProbeExtra(t *testing.T) { remote := map[string]any{ - "crs_account_id": "remote-1", - UpstreamBillingProbeEnabledExtraKey: true, - UpstreamBillingProbeExtraKey: map[string]any{"status": "remote"}, + "crs_account_id": "remote-1", + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: map[string]any{"status": "remote"}, } t.Run("create drops remote managed fields", func(t *testing.T) { extra := mergeMap(nil, remote) reconcileCRSUpstreamBillingProbeExtra(nil, PlatformOpenAI, AccountTypeAPIKey, map[string]any{"api_key": "new"}, extra) require.NotContains(t, extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotContains(t, extra, UpstreamBillingProbeExtraKey) }) @@ -132,8 +134,9 @@ func TestReconcileCRSUpstreamBillingProbeExtra(t *testing.T) { Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "local", "base_url": "http://127.0.0.1:8080"}, Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: false, - UpstreamBillingProbeExtraKey: map[string]any{"status": "local"}, + UpstreamBillingProbeEnabledExtraKey: false, + UpstreamBillingRateSyncEnabledExtraKey: false, + UpstreamBillingProbeExtraKey: map[string]any{"status": "local"}, }, } @@ -141,31 +144,62 @@ func TestReconcileCRSUpstreamBillingProbeExtra(t *testing.T) { extra := mergeMap(existing.Extra, remote) reconcileCRSUpstreamBillingProbeExtra(existing, existing.Platform, existing.Type, mergeMap(existing.Credentials, nil), extra) require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey]) require.Equal(t, map[string]any{"status": "local"}, extra[UpstreamBillingProbeExtraKey]) }) + t.Run("same identity preserves enabled rate sync", func(t *testing.T) { + enabled := *existing + enabled.Extra = mergeMap(existing.Extra, map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + }) + extra := mergeMap(enabled.Extra, remote) + reconcileCRSUpstreamBillingProbeExtra(&enabled, enabled.Platform, enabled.Type, mergeMap(enabled.Credentials, nil), extra) + require.Equal(t, true, extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, true, extra[UpstreamBillingRateSyncEnabledExtraKey]) + }) + t.Run("identity change keeps enabled and clears snapshot", func(t *testing.T) { extra := mergeMap(existing.Extra, remote) reconcileCRSUpstreamBillingProbeExtra(existing, PlatformOpenAI, AccountTypeAPIKey, map[string]any{"api_key": "changed"}, extra) require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey]) require.NotContains(t, extra, UpstreamBillingProbeExtraKey) }) + // API-key 平台间切换:探测资格保留(放宽后不再限 OpenAI),开关沿用本地值, + // 但平台属于探测身份,快照必须作废。 + for _, target := range []struct { + name string + platform string + }{ + {name: "anthropic api key", platform: PlatformAnthropic}, + {name: "gemini api key", platform: PlatformGemini}, + } { + t.Run(target.name+" keeps enabled and clears snapshot", func(t *testing.T) { + extra := mergeMap(existing.Extra, remote) + reconcileCRSUpstreamBillingProbeExtra(existing, target.platform, AccountTypeAPIKey, existing.Credentials, extra) + require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, extra[UpstreamBillingRateSyncEnabledExtraKey]) + require.NotContains(t, extra, UpstreamBillingProbeExtraKey) + }) + } + for _, target := range []struct { name string platform string typeName string }{ {name: "anthropic oauth", platform: PlatformAnthropic, typeName: AccountTypeOAuth}, - {name: "anthropic api key", platform: PlatformAnthropic, typeName: AccountTypeAPIKey}, {name: "openai oauth", platform: PlatformOpenAI, typeName: AccountTypeOAuth}, {name: "gemini oauth", platform: PlatformGemini, typeName: AccountTypeOAuth}, - {name: "gemini api key", platform: PlatformGemini, typeName: AccountTypeAPIKey}, } { t.Run(target.name+" removes inapplicable state", func(t *testing.T) { extra := mergeMap(existing.Extra, remote) reconcileCRSUpstreamBillingProbeExtra(existing, target.platform, target.typeName, existing.Credentials, extra) require.NotContains(t, extra, UpstreamBillingProbeEnabledExtraKey) + require.NotContains(t, extra, UpstreamBillingRateSyncEnabledExtraKey) require.NotContains(t, extra, UpstreamBillingProbeExtraKey) }) } diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index f227ea4278..e5a72b4c44 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -1162,6 +1162,7 @@ func reconcileCRSUpstreamBillingProbeExtra( ) { for _, key := range []string{ UpstreamBillingProbeEnabledExtraKey, + UpstreamBillingRateSyncEnabledExtraKey, UpstreamBillingProbeExtraKey, OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, @@ -1173,9 +1174,14 @@ func reconcileCRSUpstreamBillingProbeExtra( return } target := &Account{Platform: targetPlatform, Type: targetType, Credentials: targetCredentials} - if targetPlatform == PlatformOpenAI && targetType == AccountTypeAPIKey { + if IsUpstreamBillingProbeIdentity(targetPlatform, targetType) { + probeEnabled := false if enabled, ok := existing.Extra[UpstreamBillingProbeEnabledExtraKey]; ok { extra[UpstreamBillingProbeEnabledExtraKey] = enabled + probeEnabled, _ = enabled.(bool) + } + if enabled, ok := existing.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool); ok { + extra[UpstreamBillingRateSyncEnabledExtraKey] = enabled && probeEnabled } if reflect.DeepEqual(upstreamBillingProbeIdentity(existing), upstreamBillingProbeIdentity(target)) { if snapshot, ok := existing.Extra[UpstreamBillingProbeExtraKey]; ok { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3e059e3069..d815ed6ef5 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -132,6 +132,20 @@ func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTi return trend, nil } +func (s *DashboardService) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) { + type usageTrendWithFiltersRepo interface { + GetUsageTrendWithUsageFilters(context.Context, time.Time, time.Time, string, usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) + } + if repo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok { + trend, err := repo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters) + if err != nil { + return nil, fmt.Errorf("get usage trend with usage filters: %w", err) + } + return trend, nil + } + return s.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType) +} + func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) { stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) if err != nil { @@ -161,6 +175,21 @@ func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) } +func (s *DashboardService) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) { + normalizedSource := usagestats.NormalizeModelSource(modelSource) + type modelStatsWithFiltersRepo interface { + GetModelStatsWithUsageFiltersBySource(context.Context, time.Time, time.Time, usagestats.UsageLogFilters, string) ([]usagestats.ModelStat, error) + } + if repo, ok := s.usageRepo.(modelStatsWithFiltersRepo); ok { + stats, err := repo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource) + if err != nil { + return nil, fmt.Errorf("get model stats with usage filters by source: %w", err) + } + return stats, nil + } + return s.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource) +} + func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) { stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType) if err != nil { @@ -169,6 +198,20 @@ func (s *DashboardService) GetGroupStatsWithFilters(ctx context.Context, startTi return stats, nil } +func (s *DashboardService) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) { + type groupStatsWithFiltersRepo interface { + GetGroupStatsWithUsageFilters(context.Context, time.Time, time.Time, usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) + } + if repo, ok := s.usageRepo.(groupStatsWithFiltersRepo); ok { + stats, err := repo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters) + if err != nil { + return nil, fmt.Errorf("get group stats with usage filters: %w", err) + } + return stats, nil + } + 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. func (s *DashboardService) GetGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) { results, err := s.usageRepo.GetAllGroupUsageSummary(ctx, todayStart) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 766b7381e3..2d5311bb24 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -44,6 +44,9 @@ const ( PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok PlatformComposite = domain.PlatformComposite + // PlatformKiro is retained for unsupported-platform threshold tests and legacy + // account rows. Scheduling-threshold evaluation never pauses kiro accounts. + PlatformKiro = "kiro" ) // AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 @@ -57,6 +60,14 @@ var AllowedQuotaPlatforms = []string{ PlatformGrok, } +// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 +// 仅 openai / anthropic / grok 有原生用量窗口可供评估;其他平台写入阈值无效果。 +var AllowedSchedulingThresholdPlatforms = []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGrok, +} + // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 func IsAllowedQuotaPlatform(s string) bool { for _, p := range AllowedQuotaPlatforms { @@ -131,24 +142,27 @@ const ( SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组) - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 - SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) - SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 - SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 - SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 - SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) - SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) - SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) - SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) - SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 - SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 - SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) - SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) - SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) - SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 - SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox - SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) - SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) + // 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。 + // 默认 false:非白名单域名直接拒绝(白名单严格模式)。 + SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 + SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 + SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) + SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 + SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 + SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) + SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) + SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 + SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox + SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) + SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 @@ -164,6 +178,22 @@ const ( SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key + // 腾讯天御验证码设置 + SettingKeyTencentCaptchaEnabled = "tencent_captcha_enabled" + SettingKeyTencentCaptchaAppID = "tencent_captcha_app_id" + SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key" + SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id" + SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key" + SettingKeyTencentCaptchaRegion = "tencent_captcha_region" // 站点:"cn"|"intl",决定前端 SDK 脚本与服务端接入点 + + // 阿里云验证码 2.0 设置(与 Turnstile、腾讯天御互斥,同一时间仅可启用一家) + SettingKeyAliyunCaptchaEnabled = "aliyun_captcha_enabled" // 是否启用阿里云验证码 + SettingKeyAliyunCaptchaAccessKeyID = "aliyun_captcha_access_key_id" // 阿里云 AccessKey ID + SettingKeyAliyunCaptchaAccessKeySecret = "aliyun_captcha_access_key_secret" // 阿里云 AccessKey Secret + SettingKeyAliyunCaptchaSceneID = "aliyun_captcha_scene_id" // 验证场景 ID(所有认证流程共用) + SettingKeyAliyunCaptchaPrefix = "aliyun_captcha_prefix" // 身份标,前端 SDK 初始化用 + SettingKeyAliyunCaptchaRegion = "aliyun_captcha_region" // 地域:"cn"|"sgp",决定前端脚本区域与服务端接入点 + // API Key IP 访问控制设置 SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP SettingKeyForwardedClientIPHeaders = "forwarded_client_ip_headers" // 自定义 CDN 客户端 IP 请求头(JSON 数组) @@ -271,6 +301,7 @@ const ( SettingKeyContactInfo = "contact_info" // 客服联系方式 SettingKeyDocURL = "doc_url" // 文档链接 SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) + SettingKeyCompactHomeEnabled = "compact_home_enabled" // 是否启用内置简洁首页 SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口 SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src) @@ -376,10 +407,37 @@ const ( // When false: runner skips scheduling and user-facing endpoints return an empty list. SettingKeyChannelMonitorEnabled = "channel_monitor_enabled" + // SettingKeyChannelMonitorMode selects exclusive implementation: + // "v1" active probes, "v2" passive aggregation. Default "v1" (opt-in to v2). + SettingKeyChannelMonitorMode = "channel_monitor_mode" + + // ChannelMonitorModeV1/V2 are the only accepted mode values. + ChannelMonitorModeV1 = "v1" + ChannelMonitorModeV2 = "v2" + // SettingKeyChannelMonitorDefaultIntervalSeconds controls the default interval (seconds) // pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600]. SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds" + // SettingKeyChannelMonitorHideThroughput hides RPM/TPM (and similar absolute + // throughput rates) from non-admin user-facing monitor APIs and UI, so users + // cannot reverse-estimate fleet volume from rates × window length. + // Default false (show rates). Admin endpoints always keep full metrics. + SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput" + + // 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" + + // SettingKeyGrokCrossClientModelMapEnabled, when true, includes gpt-*/codex-*/o*/claude-* + // wildcards in the default Grok account model_mapping so foreign client model names + // can reach Grok groups. Default false (no silent cross-vendor rewrite). + SettingKeyGrokCrossClientModelMapEnabled = "grok_cross_client_model_map_enabled" + + // SettingKeyGrokDefaultBaseURLMode controls the default text upstream for + // Grok accounts without an explicit credentials.base_url. + SettingKeyGrokDefaultBaseURLMode = "grok_default_base_url_mode" + // SettingKeyAvailableChannelsEnabled is a DB-backed soft switch for the "Available Channels" // user-facing aggregate view. When false: user endpoint returns an empty list and the // sidebar entry is hidden. Defaults to false (opt-in feature). @@ -525,6 +583,16 @@ const ( // 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值, // 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。 SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent" + // SettingKeyOpenAICodexClientVersion 网关对 ChatGPT 上游声明的 Codex 客户端版本号(管理员覆写)。 + // 空值表示跟随自动同步值;自动同步也没有结果时回退到内置常量。 + // 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布。 + SettingKeyOpenAICodexClientVersion = "openai_codex_client_version" + // SettingKeyOpenAICodexClientVersionSynced 自动同步任务写入的官方 Codex 最新稳定版版本号。 + // 由 OpenAICodexVersionSyncService 独占写入,面板只读展示;管理员覆写请用 + // SettingKeyOpenAICodexClientVersion。 + SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" + // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 + SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。 // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" @@ -549,6 +617,10 @@ const ( // 值为 map[platform]{daily,weekly,monthly},null/缺省 = 不限制;0 = 禁用;>0 = USD 上限。 const SettingKeyDefaultPlatformQuotas = "default_platform_quotas" +// SettingKeyAccountSchedulingThresholds —— 系统全局:按平台自动停调阈值(JSON map)。 +// 值为 map[platform]percent,1..100;100 = 禁用该平台自动停调。 +const SettingKeyAccountSchedulingThresholds = "account_scheduling_thresholds" + // SettingKeyAuthSourcePlatformQuotas 返回某 auth source 的 platform quota JSON key。 // 形如 auth_source_default_{source}_platform_quotas func SettingKeyAuthSourcePlatformQuotas(source string) string { diff --git a/backend/internal/service/email_message.go b/backend/internal/service/email_message.go new file mode 100644 index 0000000000..fc325c59c1 --- /dev/null +++ b/backend/internal/service/email_message.go @@ -0,0 +1,111 @@ +package service + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "mime" + "mime/quotedprintable" + "net/mail" + "strings" + "time" +) + +type smtpMessage struct { + envelopeFrom string + envelopeTo string + data []byte +} + +func buildSMTPMessage(config *SMTPConfig, to, subject, body string) (smtpMessage, error) { + if config == nil { + return smtpMessage{}, errors.New("missing SMTP configuration") + } + + fromAddress, err := parseSMTPAddress(config.From, "from") + if err != nil { + return smtpMessage{}, err + } + recipientAddress, err := parseSMTPAddress(to, "recipient") + if err != nil { + return smtpMessage{}, err + } + messageID, err := generateEmailMessageID(fromAddress.Address, config.Host) + if err != nil { + return smtpMessage{}, fmt.Errorf("generate message ID: %w", err) + } + + fromName := sanitizeEmailHeader(config.FromName) + if strings.TrimSpace(fromName) == "" { + fromName = fromAddress.Name + } + fromHeader := (&mail.Address{ + Name: fromName, + Address: fromAddress.Address, + }).String() + toHeader := (&mail.Address{ + Name: recipientAddress.Name, + Address: recipientAddress.Address, + }).String() + subjectHeader := mime.QEncoding.Encode("UTF-8", sanitizeEmailHeader(subject)) + + var message bytes.Buffer + fmt.Fprintf(&message, "From: %s\r\n", fromHeader) + fmt.Fprintf(&message, "To: %s\r\n", toHeader) + fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) + fmt.Fprintf(&message, "Message-ID: %s\r\n", messageID) + fmt.Fprintf(&message, "Subject: %s\r\n", subjectHeader) + fmt.Fprint(&message, "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ + "Content-Transfer-Encoding: quoted-printable\r\n\r\n") + + bodyWriter := quotedprintable.NewWriter(&message) + if _, err := bodyWriter.Write([]byte(body)); err != nil { + return smtpMessage{}, fmt.Errorf("encode email body: %w", err) + } + if err := bodyWriter.Close(); err != nil { + return smtpMessage{}, fmt.Errorf("close email body encoder: %w", err) + } + + return smtpMessage{ + envelopeFrom: fromAddress.Address, + envelopeTo: recipientAddress.Address, + data: message.Bytes(), + }, nil +} + +func parseSMTPAddress(value, field string) (*mail.Address, error) { + if strings.ContainsAny(value, "\r\n") { + return nil, fmt.Errorf("invalid SMTP %s address: contains a line break", field) + } + + cleaned := strings.TrimSpace(value) + address, err := mail.ParseAddress(cleaned) + if err != nil || strings.TrimSpace(address.Address) == "" { + if err == nil { + err = fmt.Errorf("address is empty") + } + return nil, fmt.Errorf("invalid SMTP %s address: %w", field, err) + } + return address, nil +} + +func generateEmailMessageID(fromAddress, smtpHost string) (string, error) { + randomID := make([]byte, 16) + if _, err := rand.Read(randomID); err != nil { + return "", err + } + + domain := strings.TrimSpace(sanitizeEmailHeader(smtpHost)) + if at := strings.LastIndexByte(fromAddress, '@'); at >= 0 && at < len(fromAddress)-1 { + domain = fromAddress[at+1:] + } + domain = strings.Trim(domain, "[]<>") + if domain == "" { + domain = "localhost" + } + + return fmt.Sprintf("<%s@%s>", hex.EncodeToString(randomID), domain), nil +} diff --git a/backend/internal/service/email_message_test.go b/backend/internal/service/email_message_test.go new file mode 100644 index 0000000000..b5632dbd05 --- /dev/null +++ b/backend/internal/service/email_message_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "bytes" + "io" + "mime" + "mime/quotedprintable" + "net/mail" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildSMTPMessageProducesStandardsCompliantMIME(t *testing.T) { + config := &SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + FromName: "Sub2API 通知", + } + body := "\n验证码:123456 & ready\n" + + message, err := buildSMTPMessage(config, "User ", "邮箱验证码", body) + require.NoError(t, err) + require.Equal(t, "reply@example.com", message.envelopeFrom) + require.Equal(t, "user@example.net", message.envelopeTo) + + parsed, err := mail.ReadMessage(bytes.NewReader(message.data)) + require.NoError(t, err) + + from, err := mail.ParseAddress(parsed.Header.Get("From")) + require.NoError(t, err) + require.Equal(t, "Sub2API 通知", from.Name) + require.Equal(t, "reply@example.com", from.Address) + + recipient, err := mail.ParseAddress(parsed.Header.Get("To")) + require.NoError(t, err) + require.Equal(t, "User", recipient.Name) + require.Equal(t, "user@example.net", recipient.Address) + + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject")) + require.NoError(t, err) + require.Equal(t, "邮箱验证码", decodedSubject) + require.NotEmpty(t, parsed.Header.Get("Date")) + _, err = mail.ParseDate(parsed.Header.Get("Date")) + require.NoError(t, err) + require.Regexp(t, regexp.MustCompile(`^<[0-9a-f]{32}@example\.com>$`), parsed.Header.Get("Message-ID")) + require.Equal(t, "1.0", parsed.Header.Get("MIME-Version")) + require.Equal(t, "quoted-printable", parsed.Header.Get("Content-Transfer-Encoding")) + + mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) + require.NoError(t, err) + require.Equal(t, "text/html", mediaType) + require.Equal(t, "UTF-8", params["charset"]) + + decodedBody, err := io.ReadAll(quotedprintable.NewReader(parsed.Body)) + require.NoError(t, err) + require.Equal(t, strings.ReplaceAll(body, "\n", "\r\n"), string(decodedBody)) +} + +func TestBuildSMTPMessagePreventsHeaderInjection(t *testing.T) { + config := &SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + FromName: "Sender\r\nBcc: hidden@example.com", + } + + message, err := buildSMTPMessage(config, "user@example.net", "Subject\r\nCc: hidden@example.com", "body") + require.NoError(t, err) + + parsed, err := mail.ReadMessage(bytes.NewReader(message.data)) + require.NoError(t, err) + require.Empty(t, parsed.Header.Get("Bcc")) + require.Empty(t, parsed.Header.Get("Cc")) + + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject")) + require.NoError(t, err) + require.Equal(t, "SubjectCc: hidden@example.com", decodedSubject) +} + +func TestBuildSMTPMessageRejectsInvalidConfiguration(t *testing.T) { + _, err := buildSMTPMessage(nil, "user@example.net", "subject", "body") + require.ErrorContains(t, err, "missing SMTP configuration") + + _, err = buildSMTPMessage(&SMTPConfig{Host: "smtp.example.com"}, "user@example.net", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP from address") + + _, err = buildSMTPMessage(&SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + }, "invalid recipient <>", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP recipient address") + + _, err = buildSMTPMessage(&SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + }, "user@example.net\r\nBcc: hidden@example.net", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP recipient address") +} + +func TestBuildSMTPMessageUsesUniqueMessageIDs(t *testing.T) { + config := &SMTPConfig{Host: "smtp.example.com", From: "reply@example.com"} + + first, err := buildSMTPMessage(config, "user@example.net", "subject", "body") + require.NoError(t, err) + second, err := buildSMTPMessage(config, "user@example.net", "subject", "body") + require.NoError(t, err) + + firstParsed, err := mail.ReadMessage(bytes.NewReader(first.data)) + require.NoError(t, err) + secondParsed, err := mail.ReadMessage(bytes.NewReader(second.data)) + require.NoError(t, err) + require.NotEqual(t, firstParsed.Header.Get("Message-ID"), secondParsed.Header.Get("Message-ID")) +} diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 663c39c2f1..8e60d9e73d 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -5,7 +5,9 @@ import ( "crypto/rand" "crypto/subtle" "crypto/tls" + "crypto/x509" "encoding/hex" + "errors" "fmt" "html" "log/slog" @@ -186,130 +188,119 @@ const smtpIOTimeout = 20 * time.Second // SendEmailWithConfig 使用指定配置发送邮件 func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error { - // Sanitize all SMTP header fields to prevent header injection (CR/LF removal). - to = sanitizeEmailHeader(to) - subject = sanitizeEmailHeader(subject) - - from := sanitizeEmailHeader(config.From) - if config.FromName != "" { - from = fmt.Sprintf("%s <%s>", sanitizeEmailHeader(config.FromName), sanitizeEmailHeader(config.From)) - } - - msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", - from, to, subject, body) - - addr := fmt.Sprintf("%s:%d", config.Host, config.Port) - auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) - - if config.UseTLS { - return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host) - } - - return s.sendMailPlain(addr, auth, config.From, to, []byte(msg), config.Host) -} - -// sendMailPlain sends mail without TLS using a dialer with timeout. -func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { - dialer := &net.Dialer{Timeout: smtpDialTimeout} - conn, err := dialer.Dial("tcp", addr) + message, err := buildSMTPMessage(config, to, subject, body) if err != nil { - return fmt.Errorf("smtp dial: %w", err) + return err } - _ = conn.SetDeadline(time.Now().Add(smtpIOTimeout)) - defer func() { _ = conn.Close() }() - client, err := smtp.NewClient(conn, host) + client, err := s.connectSMTP(config) if err != nil { - return fmt.Errorf("new smtp client: %w", err) + return err } defer func() { _ = client.Close() }() - // Opportunistic STARTTLS: upgrade to encrypted connection if the server supports it. - // This mirrors the behavior of smtp.SendMail which we replaced for timeout support. - if ok, _ := client.Extension("STARTTLS"); ok { - if err = client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { - return fmt.Errorf("starttls: %w", err) - } - } - + auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if err = client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } - if err = client.Mail(from); err != nil { + if err = client.Mail(message.envelopeFrom); err != nil { return fmt.Errorf("smtp mail: %w", err) } - if err = client.Rcpt(to); err != nil { + if err = client.Rcpt(message.envelopeTo); err != nil { return fmt.Errorf("smtp rcpt: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp data: %w", err) } - if _, err = w.Write(msg); err != nil { + if _, err = w.Write(message.data); err != nil { return fmt.Errorf("write msg: %w", err) } if err = w.Close(); err != nil { return fmt.Errorf("close writer: %w", err) } - _ = client.Quit() - return nil -} - -// sendMailTLS 使用TLS发送邮件 -func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error { - tlsConfig := &tls.Config{ - ServerName: host, - // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。 - MinVersion: tls.VersionTLS12, - } - - dialer := &net.Dialer{Timeout: smtpDialTimeout} - conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) - if err != nil { - return fmt.Errorf("tls dial: %w", err) - } - _ = conn.SetDeadline(time.Now().Add(smtpIOTimeout)) - defer func() { _ = conn.Close() }() - - client, err := smtp.NewClient(conn, host) - if err != nil { - return fmt.Errorf("new smtp client: %w", err) - } - defer func() { _ = client.Close() }() - - if err = client.Auth(auth); err != nil { - return fmt.Errorf("smtp auth: %w", err) - } - - if err = client.Mail(from); err != nil { - return fmt.Errorf("smtp mail: %w", err) - } - - if err = client.Rcpt(to); err != nil { - return fmt.Errorf("smtp rcpt: %w", err) - } - - w, err := client.Data() - if err != nil { - return fmt.Errorf("smtp data: %w", err) - } - - _, err = w.Write(msg) - if err != nil { - return fmt.Errorf("write msg: %w", err) - } - - err = w.Close() - if err != nil { - return fmt.Errorf("close writer: %w", err) - } - // Email is sent successfully after w.Close(), ignore Quit errors // Some SMTP servers return non-standard responses on QUIT _ = client.Quit() return nil } +// smtpTestRootCAs 仅供单元测试注入自签 CA,生产环境始终为 nil(走系统信任链)。 +var smtpTestRootCAs *x509.CertPool + +func smtpTLSConfig(host string) *tls.Config { + return &tls.Config{ + ServerName: host, + // 强制 TLS 1.2+,避免协议降级导致的弱加密风险。 + MinVersion: tls.VersionTLS12, + RootCAs: smtpTestRootCAs, + } +} + +// connectSMTP 按配置建立 SMTP 会话,发送与测试连接共用此路径, +// 保证"测试连接成功 ⇔ 实际发信可用": +// - UseTLS=true:先尝试隐式 TLS(465 语义);若服务器以明文应答 +// (587/25 等提交端口的 STARTTLS 语义),自动改走"明文连接 + 强制 STARTTLS"。 +// 两种方式都无法建立加密连接时报错,绝不明文继续。 +// - UseTLS=false:明文连接后若服务器支持 STARTTLS 则机会式升级, +// 与 smtp.SendMail 的默认行为一致。 +func (s *EmailService) connectSMTP(config *SMTPConfig) (*smtp.Client, error) { + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + dialer := &net.Dialer{Timeout: smtpDialTimeout} + tlsConfig := smtpTLSConfig(config.Host) + + if config.UseTLS { + conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig) + if err == nil { + return newSMTPClient(conn, config.Host) + } + var recordErr tls.RecordHeaderError + if !errors.As(err, &recordErr) { + return nil, fmt.Errorf("tls dial: %w", err) + } + // SMTP 服务器先发问候语:明文问候会让 TLS 握手立刻返回 + // RecordHeaderError,据此可靠判定对端期望 STARTTLS。 + return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, true) + } + + return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, false) +} + +// connectSMTPStartTLS 建立明文连接并按需升级 STARTTLS。 +// mandatory 为 true 时服务器必须支持 STARTTLS,否则报错。 +func (s *EmailService) connectSMTPStartTLS(dialer *net.Dialer, addr, host string, tlsConfig *tls.Config, mandatory bool) (*smtp.Client, error) { + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("smtp dial: %w", err) + } + client, err := newSMTPClient(conn, host) + if err != nil { + return nil, err + } + if ok, _ := client.Extension("STARTTLS"); !ok { + if mandatory { + _ = client.Close() + return nil, errors.New("smtp server does not support STARTTLS") + } + return client, nil + } + if err := client.StartTLS(tlsConfig); err != nil { + _ = client.Close() + return nil, fmt.Errorf("starttls: %w", err) + } + return client, nil +} + +func newSMTPClient(conn net.Conn, host string) (*smtp.Client, error) { + _ = conn.SetDeadline(time.Now().Add(smtpIOTimeout)) + client, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("new smtp client: %w", err) + } + return client, nil +} + // GenerateVerifyCode 生成6位数字验证码 func (s *EmailService) GenerateVerifyCode() (string, error) { const digits = "0123456789" @@ -458,49 +449,24 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string { `, html.EscapeString(siteName), code) } -// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接 +// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接。 +// 与 SendEmailWithConfig 共用 connectSMTP 建连(含 STARTTLS 升级逻辑), +// 避免出现"测试连接失败但实际发信成功"的不一致。 func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { - addr := fmt.Sprintf("%s:%d", config.Host, config.Port) - - if config.UseTLS { - tlsConfig := &tls.Config{ - ServerName: config.Host, - // 与发送逻辑一致,显式要求 TLS 1.2+。 - MinVersion: tls.VersionTLS12, - } - conn, err := tls.Dial("tcp", addr, tlsConfig) - if err != nil { - return fmt.Errorf("tls connection failed: %w", err) - } - defer func() { _ = conn.Close() }() - - client, err := smtp.NewClient(conn, config.Host) - if err != nil { - return fmt.Errorf("smtp client creation failed: %w", err) - } - defer func() { _ = client.Close() }() - - auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) - if err = client.Auth(auth); err != nil { - return fmt.Errorf("smtp authentication failed: %w", err) - } - - return client.Quit() - } - - // 非TLS连接测试 - client, err := smtp.Dial(addr) + client, err := s.connectSMTP(config) if err != nil { return fmt.Errorf("smtp connection failed: %w", err) } defer func() { _ = client.Close() }() auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) - if err = client.Auth(auth); err != nil { + if err := client.Auth(auth); err != nil { return fmt.Errorf("smtp authentication failed: %w", err) } - return client.Quit() + // 认证成功即视为连接可用;与发送路径一致,忽略 QUIT 的非标准响应。 + _ = client.Quit() + return nil } // GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters) diff --git a/backend/internal/service/email_service_smtp_test.go b/backend/internal/service/email_service_smtp_test.go new file mode 100644 index 0000000000..b9677a1af3 --- /dev/null +++ b/backend/internal/service/email_service_smtp_test.go @@ -0,0 +1,383 @@ +//go:build unit + +package service + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// newSMTPTestCert 生成 127.0.0.1/localhost 的自签证书及其信任池。 +func newSMTPTestCert(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + pool := x509.NewCertPool() + pool.AddCert(leaf) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, pool +} + +// fakeSMTPServer 是覆盖三种连接形态的最小 SMTP 服务器: +// 隐式 TLS(465 语义)、明文+STARTTLS(587 语义)、纯明文。 +type fakeSMTPServer struct { + listener net.Listener + tlsConfig *tls.Config + advertiseStartTLS bool + + mu sync.Mutex + commands []string + conns atomic.Int64 + wg sync.WaitGroup +} + +func startFakeSMTPServer(t *testing.T, implicitTLS, advertiseStartTLS bool) (*fakeSMTPServer, int) { + t.Helper() + cert, pool := newSMTPTestCert(t) + prevPool := smtpTestRootCAs + smtpTestRootCAs = pool + t.Cleanup(func() { smtpTestRootCAs = prevPool }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := &fakeSMTPServer{ + listener: listener, + tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, + advertiseStartTLS: advertiseStartTLS, + } + if implicitTLS { + srv.listener = tls.NewListener(listener, srv.tlsConfig) + } + t.Cleanup(func() { + _ = srv.listener.Close() + srv.wg.Wait() + }) + + srv.wg.Add(1) + go func() { + defer srv.wg.Done() + for { + conn, err := srv.listener.Accept() + if err != nil { + return + } + srv.conns.Add(1) + srv.wg.Add(1) + go func() { + defer srv.wg.Done() + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + srv.serve(conn, srv.advertiseStartTLS) + }() + } + }() + + port := listener.Addr().(*net.TCPAddr).Port + return srv, port +} + +func (srv *fakeSMTPServer) record(cmd string) { + srv.mu.Lock() + defer srv.mu.Unlock() + srv.commands = append(srv.commands, cmd) +} + +func (srv *fakeSMTPServer) sawCommand(prefix string) bool { + srv.mu.Lock() + defer srv.mu.Unlock() + for _, cmd := range srv.commands { + if strings.HasPrefix(strings.ToUpper(cmd), prefix) { + return true + } + } + return false +} + +func (srv *fakeSMTPServer) serve(conn net.Conn, allowStartTLS bool) { + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writeLine := func(line string) bool { + if _, err := writer.WriteString(line + "\r\n"); err != nil { + return false + } + return writer.Flush() == nil + } + if !writeLine("220 fake.test ESMTP ready") { + return + } + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + cmd := strings.TrimSpace(line) + srv.record(cmd) + upper := strings.ToUpper(cmd) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + ok := writeLine("250-fake.test") + if allowStartTLS { + ok = ok && writeLine("250-STARTTLS") + } + if !(ok && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) { + return + } + case upper == "STARTTLS" && allowStartTLS: + if !writeLine("220 2.0.0 ready to start TLS") { + return + } + tlsConn := tls.Server(conn, srv.tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return + } + srv.serveUpgraded(tlsConn) + return + case strings.HasPrefix(upper, "AUTH"): + if !writeLine("235 2.7.0 authentication successful") { + return + } + case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"): + if !writeLine("250 ok") { + return + } + case upper == "DATA": + if !writeLine("354 go ahead") { + return + } + for { + dataLine, err := reader.ReadString('\n') + if err != nil { + return + } + if strings.TrimRight(dataLine, "\r\n") == "." { + break + } + } + if !writeLine("250 message accepted") { + return + } + case upper == "QUIT": + _ = writeLine("221 bye") + return + default: + if !writeLine("250 ok") { + return + } + } + } +} + +// serveUpgraded 复用命令循环处理 STARTTLS 升级后的会话(升级后不再提供 STARTTLS)。 +func (srv *fakeSMTPServer) serveUpgraded(conn net.Conn) { + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + // net/smtp 在 StartTLS 成功后会重新发送 EHLO,直接进入命令循环即可。 + srv.serveCommands(reader, writer) +} + +func (srv *fakeSMTPServer) serveCommands(reader *bufio.Reader, writer *bufio.Writer) { + writeLine := func(line string) bool { + if _, err := writer.WriteString(line + "\r\n"); err != nil { + return false + } + return writer.Flush() == nil + } + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + cmd := strings.TrimSpace(line) + srv.record(cmd) + upper := strings.ToUpper(cmd) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + if !(writeLine("250-fake.test") && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) { + return + } + case strings.HasPrefix(upper, "AUTH"): + if !writeLine("235 2.7.0 authentication successful") { + return + } + case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"): + if !writeLine("250 ok") { + return + } + case upper == "DATA": + if !writeLine("354 go ahead") { + return + } + for { + dataLine, err := reader.ReadString('\n') + if err != nil { + return + } + if strings.TrimRight(dataLine, "\r\n") == "." { + break + } + } + if !writeLine("250 message accepted") { + return + } + case upper == "QUIT": + _ = writeLine("221 bye") + return + default: + if !writeLine("250 ok") { + return + } + } + } +} + +func smtpTestConfig(port int, useTLS bool) *SMTPConfig { + return &SMTPConfig{ + Host: "127.0.0.1", + Port: port, + Username: "user", + Password: "pass", + From: "noreply@example.com", + FromName: "Test", + UseTLS: useTLS, + } +} + +// 465 语义:UseTLS=true + 隐式 TLS 服务器,原有路径保持可用。 +func TestSMTPConnectionImplicitTLS(t *testing.T) { + srv, port := startFakeSMTPServer(t, true, false) + svc := &EmailService{} + + if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil { + t.Fatalf("expected implicit TLS connection to succeed, got: %v", err) + } + if !srv.sawCommand("EHLO") { + t.Fatal("expected server to receive EHLO") + } +} + +// 587 语义(#1470/#1488 核心场景):UseTLS=true + 明文问候的 STARTTLS 服务器, +// 隐式 TLS 失败后必须自动降级为强制 STARTTLS 并成功。 +func TestSMTPConnectionStartTLSFallbackWhenTLSEnabled(t *testing.T) { + srv, port := startFakeSMTPServer(t, false, true) + svc := &EmailService{} + + if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil { + t.Fatalf("expected STARTTLS fallback to succeed, got: %v", err) + } + if !srv.sawCommand("STARTTLS") { + t.Fatal("expected server to receive STARTTLS command") + } + if got := srv.conns.Load(); got < 2 { + t.Fatalf("expected implicit TLS attempt before STARTTLS fallback (>=2 connections), got %d", got) + } +} + +// UseTLS=true 但服务器不支持 STARTTLS:必须报错,且绝不能把凭据发到明文连接上。 +func TestSMTPConnectionMandatoryStartTLSRefusesPlaintext(t *testing.T) { + srv, port := startFakeSMTPServer(t, false, false) + svc := &EmailService{} + + err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)) + if err == nil { + t.Fatal("expected error when server does not support STARTTLS") + } + if !strings.Contains(err.Error(), "STARTTLS") { + t.Fatalf("expected STARTTLS-related error, got: %v", err) + } + if srv.sawCommand("AUTH") { + t.Fatal("credentials must not be sent over plaintext when TLS is required") + } +} + +// UseTLS=false + 服务器支持 STARTTLS:测试连接与发送路径一致,机会式升级后认证成功。 +// 这是 #1488 评论"测试连接不成功,发送测试邮件实际上能发"的回归用例。 +func TestSMTPConnectionOpportunisticStartTLSWhenTLSDisabled(t *testing.T) { + srv, port := startFakeSMTPServer(t, false, true) + svc := &EmailService{} + + if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil { + t.Fatalf("expected opportunistic STARTTLS test connection to succeed, got: %v", err) + } + if !srv.sawCommand("STARTTLS") { + t.Fatal("expected test connection to upgrade via STARTTLS like the send path") + } +} + +// UseTLS=false + 服务器不支持 STARTTLS:保持明文直连(既有行为不回归)。 +func TestSMTPConnectionPlainWhenNoStartTLS(t *testing.T) { + srv, port := startFakeSMTPServer(t, false, false) + svc := &EmailService{} + + if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil { + t.Fatalf("expected plain connection to succeed, got: %v", err) + } + if srv.sawCommand("STARTTLS") { + t.Fatal("did not expect STARTTLS command when server does not advertise it") + } +} + +// 发送路径全流程:UseTLS=true + STARTTLS 服务器(587 语义)完整走完 MAIL/RCPT/DATA。 +func TestSendEmailWithConfigStartTLSFallback(t *testing.T) { + srv, port := startFakeSMTPServer(t, false, true) + svc := &EmailService{} + + err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "

body

") + if err != nil { + t.Fatalf("expected send via STARTTLS fallback to succeed, got: %v", err) + } + if !srv.sawCommand("STARTTLS") { + t.Fatal("expected send path to upgrade via STARTTLS") + } + if !srv.sawCommand("DATA") { + t.Fatal("expected send path to reach DATA") + } +} + +// 发送路径全流程:UseTLS=true + 隐式 TLS 服务器(465 语义)保持既有行为。 +func TestSendEmailWithConfigImplicitTLS(t *testing.T) { + srv, port := startFakeSMTPServer(t, true, false) + svc := &EmailService{} + + err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "

body

") + if err != nil { + t.Fatalf("expected send via implicit TLS to succeed, got: %v", err) + } + if !srv.sawCommand("DATA") { + t.Fatal("expected send path to reach DATA") + } +} diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 902a7b0f91..4fa52cc9ce 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -523,7 +523,8 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFie require.Equal(t, "hello world", gjson.GetBytes(sentBody, "messages.0.content.0.text").String(), "messages 字段不应被修改") require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String(), "thinking 字段不应被修改") require.Equal(t, int64(5000), gjson.GetBytes(sentBody, "thinking.budget_tokens").Int(), "thinking.budget_tokens 不应被修改") - require.Equal(t, int64(1024), gjson.GetBytes(sentBody, "max_tokens").Int(), "max_tokens 不应被修改") + require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(), + "max_tokens 作为生成参数应被 count_tokens 过滤剥离") } func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokensFiltersGenerationFields(t *testing.T) { @@ -582,7 +583,8 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokensFiltersGenerationF require.Equal(t, "sys", gjson.GetBytes(sentBody, "system.0.text").String()) require.Equal(t, "hello", gjson.GetBytes(sentBody, "messages.0.content").String()) require.Equal(t, "tool", gjson.GetBytes(sentBody, "tools.0.name").String()) - require.Equal(t, int64(1024), gjson.GetBytes(sentBody, "max_tokens").Int()) + require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(), + "count_tokens 请求不得携带生成参数 max_tokens") require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String()) } diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 8d7610b936..c18d5bf047 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -269,6 +269,11 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( if input.RequestStream { streamResult, err := s.handleStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account, input.StartTime, input.RequestModel) if err != nil { + // 流中断时保留已观测到的 usage 与错误一起返回,避免上游已计量的请求 + // 完全漏记漏计费(issue #5148)。 + if partial := partialStreamUsageResult(c, resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil { + return partial, err + } return nil, err } usage = streamResult.usage @@ -285,14 +290,16 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: input.OriginalModel, - UpstreamModel: input.RequestModel, - Stream: input.RequestStream, - Duration: time.Since(input.StartTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: input.OriginalModel, + UpstreamModel: input.RequestModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: input.RequestStream, + Duration: time.Since(input.StartTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } @@ -374,6 +381,10 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( startTime time.Time, model string, ) (*streamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } if s.rateLimitService != nil { s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) } @@ -527,6 +538,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( line := ev.line if data, ok := extractAnthropicSSEDataLine(line); ok { trimmed := strings.TrimSpace(data) + observer.ObserveAnthropic([]byte(trimmed)) if anthropicStreamEventIsTerminal("", trimmed) { sawTerminalEvent = true } @@ -777,6 +789,11 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough( if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { var raw json.RawMessage diff --git a/backend/internal/service/gateway_channel_restriction_test.go b/backend/internal/service/gateway_channel_restriction_test.go index 3a2ad2ff72..0e26a00c02 100644 --- a/backend/internal/service/gateway_channel_restriction_test.go +++ b/backend/internal/service/gateway_channel_restriction_test.go @@ -29,6 +29,12 @@ func TestBillingModelForRestriction_Upstream(t *testing.T) { require.Equal(t, "", got, "upstream should return empty (per-account check needed)") } +func TestBillingModelForRestriction_ResponseModelUsesMappedPrecheck(t *testing.T) { + t.Parallel() + got := billingModelForRestriction(BillingModelSourceResponse, "claude-fable-5", "claude-fable-5") + require.Equal(t, "claude-fable-5", got) +} + func TestBillingModelForRestriction_Empty(t *testing.T) { t.Parallel() got := billingModelForRestriction("", "claude-sonnet-4-5", "claude-sonnet-4-6") diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index b0f2e5b820..bdeb2c600b 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -596,6 +596,35 @@ func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndTo "count_tokens 路径必须含 token-counting beta") } +func TestBuildCountTokensRequest_OAuthMimic_DropsInjectedMaxTokens(t *testing.T) { + // OAuth mimicry injects max_tokens=128000 for normal messages requests. It is + // invalid for Anthropic's count_tokens endpoint and must be stripped on wire. + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + + account := &Account{ID: 413, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "oauth-tok"}, + Status: StatusActive, Schedulable: true, + } + normalized, _ := normalizeClaudeOAuthRequestBody( + []byte(`{"model":"claude-sonnet-4-5","messages":[]}`), + "claude-sonnet-4-5", claudeOAuthNormalizeOptions{}, + ) + require.Equal(t, int64(128000), gjson.GetBytes(normalized, "max_tokens").Int(), + "precondition: OAuth mimicry injects the Claude Code default") + + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildCountTokensRequest( + context.Background(), c, account, normalized, + "oauth-tok", "oauth", "claude-sonnet-4-5", true, + ) + require.NoError(t, err) + require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "max_tokens").Exists(), + "count_tokens wire body must not contain max_tokens") +} + func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) { // API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 500f655660..7b66ac9596 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -584,6 +584,10 @@ func sanitizeCountTokensRequestBody(body []byte) []byte { "stream", "stop_sequences", "stop", + // Anthropic's /v1/messages/count_tokens accepts request-input fields only. + // max_tokens is a generation parameter; OAuth mimicry may inject it to + // resemble Claude Code messages requests, so it must never reach this endpoint. + "max_tokens", } { if gjson.GetBytes(out, path).Exists() { if next, ok := deleteJSONPathBytes(out, path); ok { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 5a78a19817..d10cb1ea3b 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -92,6 +92,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + beginUpstreamResponseModelObservation(c) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) { @@ -843,6 +844,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ResponseBody: body, } } + // 流中断(缺失 terminal 事件、读错误、数据间隔超时等)时保留已观测到的 + // usage 与错误一起返回,handler 在错误处理完成后照常提交 usage 记录。 + if partial := partialStreamUsageResult(c, resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil { + return partial, err + } return nil, err } usage = streamResult.usage @@ -856,14 +862,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } return &ForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, // 使用原始模型用于计费和日志 - UpstreamModel: mappedModel, - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnect, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, // 使用原始模型用于计费和日志 + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnect, }, nil } @@ -920,6 +928,10 @@ func billingModelForRestriction(source, requestedModel, channelMappedModel strin return requestedModel case BillingModelSourceUpstream: return "" + case BillingModelSourceResponse: + // The response is not available during dispatch; use mapped pricing + // for restriction prechecks and decide billing after the response. + return channelMappedModel case BillingModelSourceChannelMapped: return channelMappedModel default: diff --git a/backend/internal/service/gateway_forward_partial_usage_test.go b/backend/internal/service/gateway_forward_partial_usage_test.go new file mode 100644 index 0000000000..f6701ca8b0 --- /dev/null +++ b/backend/internal/service/gateway_forward_partial_usage_test.go @@ -0,0 +1,226 @@ +package service + +import ( + "context" + "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" +) + +// 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时, +// 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同 +// 返回,供 handler 照常提交 usage 记录。 + +func newForwardPartialUsageServiceForTest(upstream *anthropicHTTPUpstreamRecorder) *GatewayService { + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + }, + } + return &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + deferredService: &DeferredService{}, + } +} + +func newAnthropicOAuthAccountForPartialUsageTest() *Account { + return &Account{ + ID: 501, + Name: "anthropic-oauth-partial-usage", + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + }, + Status: StatusActive, + Schedulable: true, + } +} + +func TestGatewayService_Forward_StreamMissingTerminalPreservesPartialUsage(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":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + // newapi 等聚合上游的典型失败形态:message_start/message_delta 携带 usage, + // 但流在 message_stop 前直接结束。 + upstreamSSE := strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-latest","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":7}}}`, + "", + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`, + "", + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":null},"usage":{"output_tokens":5}}`, + "", + "", + }, "\n") + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"rid-partial"}, + }, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := newForwardPartialUsageServiceForTest(upstream) + account := newAnthropicOAuthAccountForPartialUsageTest() + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Contains(t, err.Error(), "missing terminal event") + require.NotNil(t, result, "流中断但已观测到 usage 时必须返回部分结果用于计费") + require.True(t, result.Stream) + require.Equal(t, 11, result.Usage.InputTokens) + require.Equal(t, 7, result.Usage.CacheReadInputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.Equal(t, "rid-partial", result.RequestID) + require.NotNil(t, result.FirstTokenMs) +} + +func TestGatewayService_Forward_StreamReadErrorAfterOutputPreservesPartialUsage(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":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + // message_start 已写出(含 usage),随后上游连接异常中断。 + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &streamReadCloser{ + payload: []byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":4}}}\n\n"), + err: io.ErrUnexpectedEOF, + }, + }} + svc := newForwardPartialUsageServiceForTest(upstream) + account := newAnthropicOAuthAccountForPartialUsageTest() + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Contains(t, err.Error(), "stream read error") + require.NotNil(t, result, "已写出内容后的读错误必须保留部分 usage") + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.CacheCreationInputTokens) +} + +func TestGatewayService_Forward_StreamErrorWithoutUsageReturnsNilResult(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":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + // 只有 ping、没有任何 usage 的流中断:不应产生零 usage 的幽灵账单记录。 + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("event: ping\ndata: {\"type\": \"ping\"}\n\n")), + }} + svc := newForwardPartialUsageServiceForTest(upstream) + account := newAnthropicOAuthAccountForPartialUsageTest() + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Contains(t, err.Error(), "missing terminal event") + require.Nil(t, result, "无已观测 usage 时不应返回部分结果") +} + +func TestGatewayService_Forward_FailoverErrorKeepsNilResult(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":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + // 未向客户端写出任何字节前的读错误会包成 UpstreamFailoverError 走换号重试。 + // 该路径必须保持 result=nil:failover 成功后按成功请求计费,双份结果会重复计费。 + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &streamReadCloser{ + err: errors.New("connection reset by peer"), + }, + }} + svc := newForwardPartialUsageServiceForTest(upstream) + account := newAnthropicOAuthAccountForPartialUsageTest() + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr)) + require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费") +} + +func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(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-7-sonnet-20250219","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + parsed := &ParsedRequest{ + Body: NewRequestBodyRef(body), + Model: "claude-3-7-sonnet-20250219", + Stream: true, + } + + upstreamSSE := strings.Join([]string{ + `data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cache_read_input_tokens":2}}}`, + "", + `data: {"type":"message_delta","usage":{"output_tokens":3}}`, + "", + }, "\n") + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"rid-pass-partial"}, + }, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := newForwardPartialUsageServiceForTest(upstream) + account := newAnthropicAPIKeyAccountForTest() + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.Error(t, err) + require.Contains(t, err.Error(), "missing terminal event") + require.NotNil(t, result, "透传流中断但已观测到 usage 时必须返回部分结果用于计费") + require.True(t, result.Stream) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 2, result.Usage.CacheReadInputTokens) + require.Equal(t, 3, result.Usage.OutputTokens) + require.Equal(t, "claude-3-7-sonnet-20250219", result.Model) +} diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 13fc32165f..48cfc358a6 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -144,6 +144,20 @@ func (s *stickyGatewayCacheHotpathStub) DeleteSessionAccountID(ctx context.Conte return nil } +func (s *stickyGatewayCacheHotpathStub) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (s *stickyGatewayCacheHotpathStub) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (s *stickyGatewayCacheHotpathStub) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (s *stickyGatewayCacheHotpathStub) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { s.listByGroupCalls.Add(1) if s.err != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 7b5d967cf5..ac734a3642 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -277,6 +277,20 @@ func (m *mockGatewayCacheForPlatform) DeleteSessionAccountID(ctx context.Context return nil } +func (m *mockGatewayCacheForPlatform) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForPlatform) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (m *mockGatewayCacheForPlatform) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (m *mockGatewayCacheForPlatform) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + type mockGroupRepoForGateway struct { groups map[int64]*Group getByIDCalls int diff --git a/backend/internal/service/gateway_profit_control.go b/backend/internal/service/gateway_profit_control.go new file mode 100644 index 0000000000..fe8ba14752 --- /dev/null +++ b/backend/internal/service/gateway_profit_control.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" +) + +// withGatewayProfitControlGate installs the gate only for explicitly marked +// token requests. This keeps media, metadata, and models-list paths outside +// the profit-control surface by construction. +func (s *GatewayService) withGatewayProfitControlGate(ctx context.Context, groupID *int64) context.Context { + if _, ok := gatewayTokenRequestPricingAtFromContext(ctx); !ok || groupID == nil || *groupID <= 0 { + return ctx + } + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing != nil && existing.groupID == *groupID { + return ctx + } + + group, err := s.resolveProfitControlGroup(ctx, *groupID) + if err != nil { + slog.Warn("profit_control_group_load_failed", "group_id", *groupID, "error", err) + return s.clearForeignProfitControlGate(ctx, groupID) + } + if group == nil || !group.ProfitControlEnabled || !profitControlPlatformSupported(group.Platform) { + return s.clearForeignProfitControlGate(ctx, groupID) + } + + pricingAt, _ := gatewayTokenRequestPricingAtFromContext(ctx) + billingGroup := gatewayTokenRequestBillingGroupFromContext(ctx) + if billingGroup == nil { + if ctxGroup, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(ctxGroup) { + billingGroup = ctxGroup + } else { + billingGroup = group + } + } + + downstream := billingGroup.RateMultiplier + if userID, _ := ctx.Value(ctxkey.UserID).(int64); userID > 0 { + downstream = s.ResolveUserGroupRateMultiplier(ctx, userID, billingGroup.ID, billingGroup.RateMultiplier) + } + downstream *= billingGroup.PeakMultiplierAt(pricingAt) + threshold := clampProfitControlThreshold(downstream * (1 - group.ProfitMinMargin - group.ProfitSafetyBuffer)) + + gate := &openAIProfitControlGate{ + groupID: group.ID, + platform: group.Platform, + threshold: threshold, + pricingAt: pricingAt, + } + openAIProfitControlObserverInstance.recordInstall(gate.groupID, gate.platform, gate.threshold) + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, gate) +} + +func (s *GatewayService) clearForeignProfitControlGate(ctx context.Context, groupID *int64) context.Context { + existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + if !ok || existing == nil || groupID == nil || existing.groupID == *groupID { + return ctx + } + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, (*openAIProfitControlGate)(nil)) +} + +func (s *GatewayService) resolveProfitControlGroup(ctx context.Context, groupID int64) (*Group, error) { + if group, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(group) && group.ID == groupID { + return group, nil + } + if s.schedulerSnapshot != nil { + // Lite 读取:门只用平台/倍率/利润/高峰字段,不需要账号计数聚合。 + return s.schedulerSnapshot.GetGroupByIDLite(ctx, groupID) + } + return s.resolveGroupByID(ctx, groupID) +} + +// GatewayProfitControlVetoLatest performs the terminal post-slot check against +// the latest scheduler snapshot. Snapshot read failures are deliberately +// fail-open to preserve availability, but are observable. +func (s *GatewayService) GatewayProfitControlVetoLatest(ctx context.Context, selected *Account) (*Account, bool, string) { + return profitControlVetoLatest(ctx, selected, s.schedulerSnapshot) +} + +func profitControlVetoLatest(ctx context.Context, selected *Account, snapshot *SchedulerSnapshotService) (*Account, bool, string) { + gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + if gate == nil || selected == nil { + return selected, false, "" + } + latest := selected + if snapshot != nil { + refreshed, err := snapshot.GetAccount(ctx, selected.ID) + if err != nil || refreshed == nil { + slog.Warn("profit_control_account_refresh_failed", "group_id", gate.groupID, "platform", gate.platform, "account_id", selected.ID, "error", err) + openAIProfitControlObserverInstance.recordRefreshFailure(gate.groupID, gate.platform, gate.threshold) + } else if !refreshed.UpdatedAt.Before(selected.UpdatedAt) { + // 选号路径可能已做过 DB recheck,selected 比缓存快照更新鲜;只有 + // 快照不落后时才替换,避免终检把新鲜账号换回较旧的缓存对象。 + latest = refreshed + } + } + vetoed, reason := openAIProfitControlVetoReason(ctx, latest) + return latest, vetoed, reason +} + +func (s *GatewayService) isGatewayAccountProfitEligible(ctx context.Context, account *Account) bool { + vetoed, _ := openAIProfitControlVetoReason(ctx, account) + return !vetoed +} + +func gatewayProfitControlGateActive(ctx context.Context) bool { + gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + return gate != nil +} diff --git a/backend/internal/service/gateway_profit_control_v2_test.go b/backend/internal/service/gateway_profit_control_v2_test.go new file mode 100644 index 0000000000..c05f4501e1 --- /dev/null +++ b/backend/internal/service/gateway_profit_control_v2_test.go @@ -0,0 +1,477 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +func gatewayProfitTestGroup(id int64, platform string) *Group { + return &Group{ + ID: id, + Name: "profit-" + platform, + Platform: platform, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 0.5, + SubscriptionType: SubscriptionTypeStandard, + ProfitControlEnabled: true, + ProfitMinMargin: 0, + ProfitSafetyBuffer: 0, + } +} + +func gatewayProfitTestContext(group *Group) context.Context { + ctx := context.WithValue(context.Background(), ctxkey.Group, group) + ctx, _ = WithGatewayTokenRequestPricing(ctx) + return ctx +} + +func gatewayProfitTestAccount(id int64, platform string, rate float64, groupID int64) Account { + return Account{ + ID: id, + Name: "account", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 2, + Priority: 1, + RateMultiplier: &rate, + AccountGroups: []AccountGroup{{AccountID: id, GroupID: groupID}}, + GroupIDs: []int64{groupID}, + } +} + +func TestGatewayProfitControlInstallsForFivePlatformsOnlyOnTokenRequests(t *testing.T) { + for _, platform := range []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGemini, + PlatformGrok, + PlatformAntigravity, + } { + t.Run(platform, func(t *testing.T) { + group := gatewayProfitTestGroup(101, platform) + groupID := group.ID + svc := &GatewayService{} + + tokenCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &groupID) + gate, _ := tokenCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.NotNil(t, gate) + require.Equal(t, platform, gate.platform) + require.InDelta(t, 0.5, gate.threshold, 1e-12) + + metadataCtx := context.WithValue(context.Background(), ctxkey.Group, group) + metadataCtx = svc.withGatewayProfitControlGate(metadataCtx, &groupID) + gate, _ = metadataCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.Nil(t, gate, "未显式标记为 token 请求的入口不得装门") + }) + } +} + +func TestGatewayProfitControlCompositeBillingUsesScheduledMemberConfig(t *testing.T) { + billingGroup := &Group{ + ID: 201, + Platform: PlatformComposite, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 0.4, + SubscriptionType: SubscriptionTypeStandard, + } + memberGroup := gatewayProfitTestGroup(202, PlatformAnthropic) + memberGroup.RateMultiplier = 99 + memberGroup.ProfitMinMargin = 0.25 + + ctx := context.WithValue(context.Background(), ctxkey.Group, billingGroup) + ctx, pricingAt := WithGatewayTokenRequestPricing(ctx) + svc := &GatewayService{ + schedulerSnapshot: NewSchedulerSnapshotService( + nil, + nil, + nil, + profitControlGroupRepo{group: memberGroup}, + nil, + ), + } + ctx = svc.withGatewayProfitControlGate(ctx, &memberGroup.ID) + gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.NotNil(t, gate) + require.Equal(t, memberGroup.ID, gate.groupID) + require.Equal(t, PlatformAnthropic, gate.platform) + require.Equal(t, pricingAt, gate.pricingAt) + require.InDelta(t, 0.4*(1-0.25), gate.threshold, 1e-12, "D 必须取 composite 计费父分组,margin 取被调度成员分组") +} + +func TestGatewayProfitControlGroupLoadFailureClearsForeignGate(t *testing.T) { + billingGroup := &Group{ + ID: 211, + Platform: PlatformComposite, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 0.4, + SubscriptionType: SubscriptionTypeStandard, + } + targetGroupID := int64(212) + ctx := gatewayProfitTestContext(billingGroup) + ctx = context.WithValue(ctx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + groupID: 210, + platform: PlatformAnthropic, + threshold: 0.1, + }) + svc := &GatewayService{ + schedulerSnapshot: NewSchedulerSnapshotService( + nil, + nil, + nil, + profitControlFailingGroupRepo{}, + nil, + ), + } + + ctx = svc.withGatewayProfitControlGate(ctx, &targetGroupID) + gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.Nil(t, gate, "加载新分组失败时必须清除其他分组遗留的门") + + account := gatewayProfitTestAccount(213, PlatformAnthropic, 0.8, targetGroupID) + require.True(t, svc.isGatewayAccountProfitEligible(ctx, &account), "配置读取失败按既定语义 fail-open") +} + +type profitControlFailingGroupRepo struct { + GroupRepository +} + +func (profitControlFailingGroupRepo) GetByIDLite(context.Context, int64) (*Group, error) { + return nil, errors.New("group cache unavailable") +} + +// 见 profitControlGroupRepo.GetByID:利润门必须走不带账号计数聚合的 lite 读取。 +func (profitControlFailingGroupRepo) GetByID(context.Context, int64) (*Group, error) { + panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") +} + +func TestGatewayProfitControlLegacyMixedAndRoutedSelection(t *testing.T) { + t.Run("legacy single-platform selection", func(t *testing.T) { + group := gatewayProfitTestGroup(111, PlatformGrok) + cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) + expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{expensive, cheap}, + accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive}, + } + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: testConfig(), + } + + selected, err := svc.SelectAccountForModelWithExclusions( + gatewayProfitTestContext(group), &group.ID, "", "", nil, + ) + require.NoError(t, err) + require.Equal(t, cheap.ID, selected.ID) + + _, err = svc.SelectAccountForModelWithExclusions( + gatewayProfitTestContext(group), &group.ID, "", "", map[int64]struct{}{cheap.ID: {}}, + ) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + }) + + t.Run("mixed routing filters the routed account", func(t *testing.T) { + group := gatewayProfitTestGroup(112, PlatformAnthropic) + group.ModelRoutingEnabled = true + group.ModelRouting = map[string][]int64{"claude-test": {2, 1}} + cheap := gatewayProfitTestAccount(1, PlatformAntigravity, 0.2, group.ID) + cheap.Extra = map[string]any{"mixed_scheduling": true} + cheap.Credentials = map[string]any{"model_mapping": map[string]any{"claude-test": "claude-test"}} + expensive := gatewayProfitTestAccount(2, PlatformAnthropic, 0.8, group.ID) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{expensive, cheap}, + accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive}, + } + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: testConfig(), + } + + selected, err := svc.SelectAccountForModelWithExclusions( + gatewayProfitTestContext(group), &group.ID, "", "claude-test", nil, + ) + require.NoError(t, err) + require.Equal(t, cheap.ID, selected.ID) + }) +} + +func TestGatewayProfitControlLoadAwareSelectionAndFailover(t *testing.T) { + group := gatewayProfitTestGroup(121, PlatformGrok) + cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) + expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{expensive, cheap}, + accountsByID: map[int64]*Account{cheap.ID: &cheap, expensive.ID: &expensive}, + } + cfg := &config.Config{RunMode: config.RunModeStandard} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + } + + result, err := svc.SelectAccountWithLoadAwareness( + gatewayProfitTestContext(group), &group.ID, "", "", nil, "", 0, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, cheap.ID, result.Account.ID) + if result.ReleaseFunc != nil { + result.ReleaseFunc() + } + + result, err = svc.SelectAccountWithLoadAwareness( + gatewayProfitTestContext(group), + &group.ID, + "", + "", + map[int64]struct{}{cheap.ID: {}}, + "", + 0, + ) + require.Nil(t, result) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) +} + +func TestGatewayProfitControlStickyVetoKeepsBindingUntilRateRecovers(t *testing.T) { + group := gatewayProfitTestGroup(131, PlatformAnthropic) + expensive := gatewayProfitTestAccount(1, PlatformAnthropic, 0.8, group.ID) + cheap := gatewayProfitTestAccount(2, PlatformAnthropic, 0.2, group.ID) + repo := &mockAccountRepoForPlatform{ + accounts: []Account{expensive, cheap}, + accountsByID: map[int64]*Account{expensive.ID: &expensive, cheap.ID: &cheap}, + } + cache := &mockGatewayCacheForPlatform{ + sessionBindings: map[string]int64{"sticky-profit": expensive.ID}, + } + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: testConfig(), + } + ctx := gatewayProfitTestContext(group) + + selected, err := svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil) + require.NoError(t, err) + require.Equal(t, cheap.ID, selected.ID) + require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "候选过滤不得覆盖旧粘性绑定") + + require.NoError(t, svc.BindStickySessionAfterProfitAdmission( + svc.withGatewayProfitControlGate(ctx, &group.ID), + &group.ID, + "sticky-profit", + cheap.ID, + )) + require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "终检通过的 fallback 账号也不得覆盖旧绑定") + require.Zero(t, cache.deletedSessions["sticky-profit"]) + + recovered := expensive + recoveredRate := 0.2 + recovered.RateMultiplier = &recoveredRate + repo.accounts[0] = recovered + repo.accountsByID[recovered.ID] = &repo.accounts[0] + + selected, err = svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil) + require.NoError(t, err) + require.Equal(t, recovered.ID, selected.ID, "倍率恢复后应重新命中原粘性账号") + require.Zero(t, cache.deletedSessions["sticky-profit"]) +} + +type gatewayProfitSnapshotCache struct { + SchedulerCache + account *Account + err error +} + +func (c *gatewayProfitSnapshotCache) GetAccount(context.Context, int64) (*Account, error) { + return c.account, c.err +} + +type gatewayProfitAccountRepo struct { + AccountRepository + account *Account + err error +} + +func (r gatewayProfitAccountRepo) GetByID(context.Context, int64) (*Account, error) { + return r.account, r.err +} + +func TestGatewayProfitControlTerminalRefreshUsesReplacementObject(t *testing.T) { + selected := gatewayProfitTestAccount(141, PlatformGemini, 0.2, 1) + replacement := selected + expensiveRate := 0.8 + replacement.RateMultiplier = &expensiveRate + + snapshot := NewSchedulerSnapshotService( + &gatewayProfitSnapshotCache{account: &replacement}, + nil, + gatewayProfitAccountRepo{}, + nil, + nil, + ) + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + groupID: 1, + platform: PlatformGemini, + threshold: 0.5, + }) + + latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot) + require.Same(t, &replacement, latest) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + require.InDelta(t, 0.2, *selected.RateMultiplier, 1e-12, "测试必须替换缓存对象,不能原地修改旧指针") +} + +func TestGatewayProfitControlTerminalRefreshFallsBackFromCacheToDatabase(t *testing.T) { + selected := gatewayProfitTestAccount(145, PlatformAnthropic, 0.2, 1) + replacement := selected + expensiveRate := 0.8 + replacement.RateMultiplier = &expensiveRate + + snapshot := NewSchedulerSnapshotService( + &gatewayProfitSnapshotCache{err: errors.New("cache unavailable")}, + nil, + gatewayProfitAccountRepo{account: &replacement}, + nil, + nil, + ) + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + groupID: 1, + platform: PlatformAnthropic, + threshold: 0.5, + }) + + latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot) + require.Same(t, &replacement, latest) + require.True(t, vetoed, "缓存读取失败时必须继续从数据库重读,不能直接使用选号旧对象") + require.Equal(t, openAIProfitFilterReasonThreshold, reason) +} + +func TestGatewayProfitControlTerminalRefreshFailureFallsBackToSelectedObject(t *testing.T) { + selected := gatewayProfitTestAccount(151, PlatformAntigravity, 0.2, 1) + snapshot := NewSchedulerSnapshotService( + &gatewayProfitSnapshotCache{err: errors.New("cache unavailable")}, + nil, + gatewayProfitAccountRepo{err: errors.New("database unavailable")}, + nil, + nil, + ) + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + groupID: 1, + platform: PlatformAntigravity, + threshold: 0.5, + }) + + latest, vetoed, reason := profitControlVetoLatest(ctx, &selected, snapshot) + require.Same(t, &selected, latest) + require.False(t, vetoed) + require.Empty(t, reason) +} + +// 选号结果携带门:门安装在调度栈局部 ctx 上,handler 必须经 +// ContextWithSelectionProfitGate 重放后终检与准入后绑定才可见(评审修复回归)。 +func TestGatewayProfitControlSelectionCarriesGateToHandlerContext(t *testing.T) { + group := gatewayProfitTestGroup(1, PlatformAnthropic) + svc := &GatewayService{} + expensive := gatewayProfitTestAccount(161, PlatformAnthropic, 0.9, group.ID) + + gateCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &group.ID) + selection, err := svc.newSelectionResult(gateCtx, &expensive, true, nil, nil) + require.NoError(t, err) + require.True(t, selection.ProfitGateActive(), "选号结果必须携带调度栈内生效的门") + + // 修复前的缺陷形态:handler 原始 ctx 不含门,终检退化为空操作。 + _, vetoed, _ := svc.GatewayProfitControlVetoLatest(context.Background(), &expensive) + require.False(t, vetoed, "对照组:不重放门时终检确实看不到门") + + handlerCtx := ContextWithSelectionProfitGate(context.Background(), selection) + latest, vetoed, reason := svc.GatewayProfitControlVetoLatest(handlerCtx, &expensive) + require.True(t, vetoed, "重放门后终检必须真实生效") + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + require.NotNil(t, latest) + + // 无门选号不携带门,重放为无操作。 + plain, err := svc.newSelectionResult(context.Background(), &expensive, true, nil, nil) + require.NoError(t, err) + require.False(t, plain.ProfitGateActive()) + require.Equal(t, context.Background(), ContextWithSelectionProfitGate(context.Background(), plain)) +} + +// 生图意图不关门(H1/H2 回归锚点):/v1/responses 混合请求即使带生图声明, +// token 定价上下文照常装配,共享门照常安装并否决越线账号。 +func TestGatewayProfitControlImageIntentDoesNotDisableGate(t *testing.T) { + group := gatewayProfitTestGroup(2, PlatformAnthropic) + svc := &GatewayService{} + expensive := gatewayProfitTestAccount(162, PlatformAnthropic, 0.9, group.ID) + + ctx := gatewayProfitTestContext(group) + ctx = WithOpenAIImageGenerationIntent(ctx) + gateCtx := svc.withGatewayProfitControlGate(ctx, &group.ID) + require.False(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive), + "请求体里的生图声明(含被动 image_gen namespace)不得关闭利润门") +} + +// 无门时准入后绑定回退官方 eager 语义;门下读失败保守不写(评审 M-Bind 回归)。 +func TestGatewayProfitControlAfterAdmissionBindSemantics(t *testing.T) { + groupID := int64(3) + expensiveID := int64(171) + cheapID := int64(172) + + t.Run("eager without gate", func(t *testing.T) { + cache := &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{"s": expensiveID}} + svc := &GatewayService{cache: cache} + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(context.Background(), &groupID, "s", cheapID)) + require.Equal(t, cheapID, cache.sessionBindings["s"], "无门时保持既有 eager 绑定行为") + }) + + t.Run("gated read failure is conservative", func(t *testing.T) { + // mock 的 miss 返回非 sentinel 错误,等价于 Redis 读失败:门下保守不写。 + cache := &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{}} + svc := &GatewayService{cache: cache} + gate := &openAIProfitControlGate{groupID: groupID, platform: PlatformAnthropic, threshold: 0.5} + gateCtx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate) + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(gateCtx, &groupID, "absent", cheapID)) + require.NotContains(t, cache.sessionBindings, "absent") + }) + + t.Run("gated sentinel miss binds", func(t *testing.T) { + cache := &sentinelMissGatewayCache{mockGatewayCacheForPlatform: &mockGatewayCacheForPlatform{sessionBindings: map[string]int64{}}} + svc := &GatewayService{cache: cache} + gate := &openAIProfitControlGate{groupID: groupID, platform: PlatformAnthropic, threshold: 0.5} + gateCtx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate) + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(gateCtx, &groupID, "fresh", cheapID)) + require.Equal(t, cheapID, cache.sessionBindings["fresh"], "门下无既有绑定(sentinel miss)应建立粘性") + }) +} + +// sentinelMissGatewayCache 让 miss 返回与真实仓库一致的 ErrStickySessionNotFound。 +type sentinelMissGatewayCache struct { + *mockGatewayCacheForPlatform +} + +func (c *sentinelMissGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) { + if id, ok := c.sessionBindings[sessionHash]; ok { + return id, nil + } + return 0, ErrStickySessionNotFound +} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 81aff58278..517d4723cb 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -345,6 +345,53 @@ func TestGatewayServiceRecordUsage_PeakRateAffectsTokenModeImageOutputTokens(t * require.InDelta(t, expectedActual, userRepo.lastAmount, 1e-12) } +func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T) { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity} { + t.Run(platform, func(t *testing.T) { + groupID := int64(903) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.resolver = newOpenAITokenImageChannelPricingResolverForTest(t, groupID, "gemini-image") + + pricingAt := time.Date(2026, time.January, 1, 0, 30, 0, 0, time.UTC) + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_explicit_pricing_at_" + platform, + Model: "gemini-image", + ImageCount: 1, + Usage: ClaudeUsage{ + InputTokens: 1000, + OutputTokens: 600, + ImageOutputTokens: 100, + }, + }, + APIKey: &APIKey{ + ID: 803, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: platform, + RateMultiplier: 1.0, + SubscriptionType: SubscriptionTypeSubscription, + PeakRateEnabled: true, + PeakStart: "00:00", + PeakEnd: "01:00", + PeakRateMultiplier: 3.0, + }, + }, + User: &User{ID: 603}, + Account: &Account{ID: 703, Platform: platform}, + PricingAt: pricingAt, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 3.0, usageRepo.lastLog.RateMultiplier) + }) + } +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} @@ -530,9 +577,10 @@ func TestGatewayServiceRecordUsage_DroppedUsageLogFallsBackToSyncCreate(t *testi require.NoError(t, usageRepo.lastCtxErr) } -func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) { +func TestGatewayServiceRecordUsage_BillingErrorWritesUnsettledUsageLog(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{} - billingRepo := &openAIRecordUsageBillingRepoStub{err: context.DeadlineExceeded} + billingErr := errors.New("billing tx failed") + billingRepo := &openAIRecordUsageBillingRepoStub{err: billingErr} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} svc := newGatewayRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo) @@ -552,9 +600,16 @@ func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) Account: &Account{ID: 705}, }) - require.Error(t, err) + require.ErrorIs(t, err, billingErr) require.Equal(t, 1, billingRepo.calls) - require.Equal(t, 0, usageRepo.calls) + require.Equal(t, 1, usageRepo.calls) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 10, usageRepo.lastLog.InputTokens) + require.Equal(t, 6, usageRepo.lastLog.OutputTokens) + require.Greater(t, usageRepo.lastLog.InputCost, 0.0) + require.Greater(t, usageRepo.lastLog.OutputCost, 0.0) + require.Greater(t, usageRepo.lastLog.TotalCost, 0.0) + require.Zero(t, usageRepo.lastLog.ActualCost) } func TestGatewayServiceRecordUsage_ReasoningEffortPersisted(t *testing.T) { diff --git a/backend/internal/service/gateway_request_pricing.go b/backend/internal/service/gateway_request_pricing.go new file mode 100644 index 0000000000..d86d9b8bdd --- /dev/null +++ b/backend/internal/service/gateway_request_pricing.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +type gatewayTokenRequestPricingAtCtxKey struct{} +type gatewayTokenRequestBillingGroupCtxKey struct{} + +// WithGatewayTokenRequestPricing marks a shared-gateway request as token billed +// and freezes the downstream pricing instant for its whole lifetime. Media and +// metadata-only handlers deliberately do not call this helper. +func WithGatewayTokenRequestPricing(ctx context.Context) (context.Context, time.Time) { + if ctx == nil { + ctx = context.Background() + } + pricingAt := timezone.Now() + ctx = context.WithValue(ctx, gatewayTokenRequestPricingAtCtxKey{}, pricingAt) + // 调度过程中可能因 fallback/composite 路由覆盖 ctxkey.Group;计费 D 仍必须 + // 使用认证时刻的父分组,和最终 RecordUsage 的计费归属保持一致。 + if group, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(group) { + ctx = context.WithValue(ctx, gatewayTokenRequestBillingGroupCtxKey{}, group) + } + return ctx, pricingAt +} + +func gatewayTokenRequestPricingAtFromContext(ctx context.Context) (time.Time, bool) { + if ctx == nil { + return time.Time{}, false + } + pricingAt, ok := ctx.Value(gatewayTokenRequestPricingAtCtxKey{}).(time.Time) + return pricingAt, ok && !pricingAt.IsZero() +} + +// GatewayTokenRequestPricingAtFromContext exposes the frozen instant to +// handlers before they detach asynchronous usage-recording work. +func GatewayTokenRequestPricingAtFromContext(ctx context.Context) time.Time { + pricingAt, _ := gatewayTokenRequestPricingAtFromContext(ctx) + return pricingAt +} + +func gatewayTokenRequestBillingGroupFromContext(ctx context.Context) *Group { + if ctx == nil { + return nil + } + group, _ := ctx.Value(gatewayTokenRequestBillingGroupCtxKey{}).(*Group) + if IsGroupContextValid(group) { + return group + } + return nil +} diff --git a/backend/internal/service/gateway_request_pricing_test.go b/backend/internal/service/gateway_request_pricing_test.go new file mode 100644 index 0000000000..3f0faf14eb --- /dev/null +++ b/backend/internal/service/gateway_request_pricing_test.go @@ -0,0 +1,20 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWithGatewayTokenRequestPricingMarksOnlyExplicitTokenRequests(t *testing.T) { + ctx, pricingAt := WithGatewayTokenRequestPricing(context.Background()) + + got, ok := gatewayTokenRequestPricingAtFromContext(ctx) + require.True(t, ok) + require.Equal(t, pricingAt, got) + require.Equal(t, pricingAt, GatewayTokenRequestPricingAtFromContext(ctx)) + require.True(t, GatewayTokenRequestPricingAtFromContext(context.Background()).IsZero()) +} diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 99cfd02c4e..be63f804f5 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -64,6 +64,7 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context // 无分组时只使用原生 anthropic 平台 platform = PlatformAnthropic } + ctx = s.withGatewayProfitControlGate(ctx, groupID) // Claude Code 限制可能已将 groupID 解析为 fallback group, // 渠道限制预检查必须使用解析后的分组。 @@ -116,6 +117,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return nil, err } ctx = s.withGroupContext(ctx, group) + ctx = s.withGatewayProfitControlGate(ctx, groupID) // Claude Code 限制可能已将 groupID 解析为 fallback group, // 渠道限制预检查必须使用解析后的分组。 @@ -284,6 +286,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } continue } + if !s.isGatewayAccountProfitEligible(ctx, account) { + continue + } if !s.isAccountAllowedForPlatform(account, platform, useMixed) { filteredPlatform++ continue @@ -339,6 +344,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro var stickyCacheMissReason string gatePass := s.isAccountSchedulableForSelection(stickyAccount) && + s.isGatewayAccountProfitEligible(ctx, stickyAccount) && s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, stickyAccount, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, stickyAccount, requestedModel) && @@ -470,7 +476,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro continue } if sessionHash != "" && s.cache != nil { - _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL) + _ = s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, item.account.ID) } if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID) @@ -523,6 +529,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro // accounts 列表构建,账号一定在分组内。而 scheduler snapshot 缓存 // 反序列化后 AccountGroups 字段为空,导致 isAccountInGroup 永远返回 false。 platformOK := s.isAccountAllowedForPlatform(account, platform, useMixed) + profitOK := s.isGatewayAccountProfitEligible(ctx, account) modelSupported := requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel) modelSchedulable := s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) quotaOK := s.isAccountSchedulableForQuota(account) @@ -536,6 +543,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "clear_sticky", clearSticky, "schedulable", schedulable, "platform_ok", platformOK, + "profit_ok", profitOK, "model_supported", modelSupported, "model_schedulable", modelSchedulable, "quota_ok", quotaOK, @@ -543,7 +551,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "rpm_ok", rpmOK, ) - if !clearSticky && platformOK && modelSupported && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { + if !clearSticky && platformOK && profitOK && modelSupported && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { // 会话数量限制检查 @@ -639,6 +647,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if !s.isAccountSchedulableForSelection(acc) { continue } + if !s.isGatewayAccountProfitEligible(ctx, acc) { + continue + } if !s.isAccountAllowedForPlatform(acc, platform, useMixed) { continue } @@ -720,7 +731,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro result.ReleaseFunc() // 释放槽位,继续尝试下一个账号 } else { if sessionHash != "" && s.cache != nil { - _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL) + _ = s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, selected.account.ID) } return s.newSelectionResult(ctx, selected.account, true, result.ReleaseFunc, nil) } @@ -768,7 +779,7 @@ func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates continue } if sessionHash != "" && s.cache != nil { - _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL) + _ = s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, acc.ID) } selection, err := s.newSelectionResult(ctx, acc, true, result.ReleaseFunc, nil) if err != nil { @@ -950,6 +961,10 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i if s.schedulerSnapshot != nil { accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform) if err == nil { + accounts = s.filterAccountsBySchedulingThreshold(ctx, accounts) + if platform == PlatformGrok || strings.EqualFold(platform, PlatformGrok) { + accounts = s.filterGrokFreeQuotaAccountsForGateway(ctx, accounts) + } slog.Debug("account_scheduling_list_snapshot", "group_id", derefGroupID(groupID), "platform", platform, @@ -1011,7 +1026,7 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i "tls_fingerprint", acc.IsTLSFingerprintEnabled()) } } - return filtered, useMixed, nil + return s.filterAccountsBySchedulingThreshold(ctx, filtered), useMixed, nil } var accounts []Account @@ -1046,6 +1061,10 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i "tls_fingerprint", acc.IsTLSFingerprintEnabled()) } } + accounts = s.filterAccountsBySchedulingThreshold(ctx, accounts) + if platform == PlatformGrok || strings.EqualFold(platform, PlatformGrok) { + accounts = s.filterGrokFreeQuotaAccountsForGateway(ctx, accounts) + } return accounts, useMixed, nil } @@ -1417,10 +1436,50 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A } func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) { + var ( + account *Account + err error + ) if s.schedulerSnapshot != nil { - return s.schedulerSnapshot.GetAccount(ctx, accountID) + account, err = s.schedulerSnapshot.GetAccount(ctx, accountID) + } else { + account, err = s.accountRepo.GetByID(ctx, accountID) } - return s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return account, err + } + if s.isAccountBlockedBySchedulingThreshold(ctx, account) { + return nil, nil + } + // Sticky / non-list selection must honor free soft-gate (same as listSchedulableAccounts). + if account.IsGrok() { + if gated := s.filterGrokFreeQuotaAccountsForGateway(ctx, []Account{*account}); len(gated) == 0 { + return nil, nil + } + } + return account, nil +} + +func (s *GatewayService) filterAccountsBySchedulingThreshold(ctx context.Context, accounts []Account) []Account { + if len(accounts) == 0 { + return accounts + } + + filtered := make([]Account, 0, len(accounts)) + for i := range accounts { + if s.isAccountBlockedBySchedulingThreshold(ctx, &accounts[i]) { + continue + } + filtered = append(filtered, accounts[i]) + } + return filtered +} + +func (s *GatewayService) isAccountBlockedBySchedulingThreshold(ctx context.Context, account *Account) bool { + if s == nil || s.rateLimitService == nil || account == nil { + return false + } + return s.rateLimitService.ApplyAccountSchedulingThreshold(ctx, account) } func (s *GatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { @@ -1442,12 +1501,12 @@ func (s *GatewayService) newSelectionResult(ctx context.Context, account *Accoun if err != nil { return nil, err } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: hydrated, Acquired: acquired, ReleaseFunc: release, WaitPlan: waitPlan, - }, nil + }), nil } // filterByMinPriority 过滤出优先级最小的账号集合 @@ -1787,7 +1846,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } - if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { + if !clearSticky && s.isGatewayAccountProfitEligible(ctx, account) && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID) } @@ -1835,6 +1894,9 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if !s.isAccountSchedulableForSelection(acc) { continue } + if !s.isGatewayAccountProfitEligible(ctx, acc) { + continue + } // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() { _ = s.accountRepo.SetError(ctx, acc.ID, @@ -1882,7 +1944,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if selected != nil { if sessionHash != "" && s.cache != nil { - if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil { + if err := s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, selected.ID); err != nil { logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err) } } @@ -1906,7 +1968,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } - if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) { + if !clearSticky && s.isGatewayAccountProfitEligible(ctx, account) && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) { return account, nil } } @@ -1946,6 +2008,9 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if !s.isAccountSchedulableForSelection(acc) { continue } + if !s.isGatewayAccountProfitEligible(ctx, acc) { + continue + } // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() { _ = s.accountRepo.SetError(ctx, acc.ID, @@ -2004,7 +2069,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, // 4. 建立粘性绑定 if sessionHash != "" && s.cache != nil { - if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil { + if err := s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, selected.ID); err != nil { logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err) } } @@ -2045,7 +2110,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } - if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) { + if !clearSticky && s.isGatewayAccountProfitEligible(ctx, account) && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) { if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy mixed routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID) @@ -2091,6 +2156,9 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if !s.isAccountSchedulableForSelection(acc) { continue } + if !s.isGatewayAccountProfitEligible(ctx, acc) { + continue + } // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() { _ = s.accountRepo.SetError(ctx, acc.ID, @@ -2142,7 +2210,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if selected != nil { if sessionHash != "" && s.cache != nil { - if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil { + if err := s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, selected.ID); err != nil { logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err) } } @@ -2166,7 +2234,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } - if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { + if !clearSticky && s.isGatewayAccountProfitEligible(ctx, account) && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { return account, nil } @@ -2203,6 +2271,9 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if !s.isAccountSchedulableForSelection(acc) { continue } + if !s.isGatewayAccountProfitEligible(ctx, acc) { + continue + } // require_privacy_set: 跳过 privacy 未设置的账号并标记异常 if schedGroup != nil && schedGroup.RequirePrivacySet && !acc.IsPrivacySet() { _ = s.accountRepo.SetError(ctx, acc.ID, @@ -2265,7 +2336,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g // 4. 建立粘性绑定 if sessionHash != "" && s.cache != nil { - if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil { + if err := s.bindGatewayStickySessionDuringSelection(ctx, groupID, sessionHash, selected.ID); err != nil { logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err) } } @@ -2281,6 +2352,8 @@ type selectionFailureStats struct { PlatformFiltered int ModelUnsupported int ModelRateLimited int + ProfitThreshold int + ProfitInvalidRate int SamplePlatformIDs []int64 SampleMappingIDs []int64 SampleRateLimitIDs []string @@ -2304,7 +2377,7 @@ func (s *GatewayService) logDetailedSelectionFailure( stats := s.collectSelectionFailureStats(ctx, accounts, requestedModel, platform, excludedIDs, allowMixedScheduling) logger.LegacyPrintf( "service.gateway", - "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", + "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", derefGroupID(groupID), requestedModel, platform, @@ -2316,6 +2389,8 @@ func (s *GatewayService) logDetailedSelectionFailure( stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, stats.SamplePlatformIDs, stats.SampleMappingIDs, stats.SampleRateLimitIDs, @@ -2353,6 +2428,10 @@ func (s *GatewayService) collectSelectionFailureStats( stats.ModelRateLimited++ remaining := acc.GetRateLimitRemainingTimeWithContext(ctx, requestedModel).Truncate(time.Second) stats.SampleRateLimitIDs = appendSelectionFailureRateSample(stats.SampleRateLimitIDs, acc.ID, remaining) + case openAIProfitFilterReasonThreshold: + stats.ProfitThreshold++ + case openAIProfitFilterReasonInvalidAccountRate: + stats.ProfitInvalidRate++ default: stats.Eligible++ } @@ -2397,6 +2476,9 @@ func (s *GatewayService) diagnoseSelectionFailure( Detail: fmt.Sprintf("remaining=%s", remaining), } } + if vetoed, reason := openAIProfitControlVetoReason(ctx, acc); vetoed { + return selectionFailureDiagnosis{Category: reason} + } return selectionFailureDiagnosis{Category: "eligible"} } @@ -2434,7 +2516,7 @@ func appendSelectionFailureRateSample(samples []string, accountID int64, remaini func summarizeSelectionFailureStats(stats selectionFailureStats) string { return fmt.Sprintf( - "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d", + "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d", stats.Total, stats.Eligible, stats.Excluded, @@ -2442,6 +2524,8 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string { stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, ) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 2f2f836ddd..0f3415fab5 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "os" @@ -20,10 +21,12 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "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/cespare/xxhash/v2" gocache "github.com/patrickmn/go-cache" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "golang.org/x/sync/singleflight" ) @@ -444,14 +447,24 @@ var allowedHeaders = map[string]bool{ "x-client-request-id": true, } +// ErrStickySessionNotFound is returned by GatewayCache.GetSessionAccountID +// when no binding exists for the session. It abstracts away the underlying +// cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound. +var ErrStickySessionNotFound = errors.New("sticky session not found") + // GatewayCache 定义网关服务的缓存操作接口。 // 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。 // // GatewayCache defines cache operations for gateway service. // Provides sticky session storage, retrieval, refresh and deletion capabilities. type GatewayCache interface { - // GetSessionAccountID 获取粘性会话绑定的账号 ID - // Get the account ID bound to a sticky session + // GetSessionAccountID 获取粘性会话绑定的账号 ID;无绑定时返回 + // ErrStickySessionNotFound,使 service 层无需依赖具体缓存实现即可 + // 区分"未绑定"与真实读取失败。 + // Get the account ID bound to a sticky session. Returns + // ErrStickySessionNotFound when no binding exists so service code can + // distinguish a miss from a real read failure without importing the + // cache driver. GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) // SetSessionAccountID 设置粘性会话与账号的绑定关系 // Set the binding between sticky session and account @@ -462,6 +475,17 @@ type GatewayCache interface { // DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理 // Delete sticky session binding, used to proactively clean up when account becomes unavailable DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error + + // Grok async video billing snapshot (create → status success). + // SetGrokVideoPendingBilling stores create-time model/duration/resolution for status billing. + SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error + // GetGrokVideoPendingBilling returns the create-time billing snapshot; miss → nil, nil. + GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error) + // ClaimGrokVideoBilled atomically marks a video request as billed (SetNX). + // Returns true when this caller won the claim; false when already billed or claim unavailable. + 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 } // derefGroupID safely dereferences *int64 to int64, returning 0 if nil @@ -537,6 +561,15 @@ type AccountSelectionResult struct { Acquired bool ReleaseFunc func() WaitPlan *AccountWaitPlan // nil means no wait allowed + // profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的 + // 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在 + // 调度栈之外做抢槽后终检与准入后粘性绑定。 + profitGate *openAIProfitControlGate +} + +// ProfitGateActive 报告本次选号是否处于利润门之下。 +func (r *AccountSelectionResult) ProfitGateActive() bool { + return r != nil && r.profitGate != nil } // ClaudeUsage 表示Claude API返回的usage信息 @@ -551,18 +584,27 @@ type ClaudeUsage struct { } // ForwardResult 转发结果 +type AudioUsage struct { + Mode string // realtime | tts | stt + DurationOrUnits float64 // minutes / million-chars / hours +} + type ForwardResult struct { RequestID string Usage ClaudeUsage Model string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. - UpstreamModel string - Stream bool - Duration time.Duration - FirstTokenMs *int // 首字时间(流式请求) - ClientDisconnect bool // 客户端是否在流式传输过程中断开 - ReasoningEffort *string + UpstreamModel string + // UpstreamResponseModel is captured from the raw successful upstream + // response before any client-facing rewrite or protocol conversion. + UpstreamResponseModel string + UpstreamResponseModelConflict bool + Stream bool + Duration time.Duration + FirstTokenMs *int // 首字时间(流式请求) + ClientDisconnect bool // 客户端是否在流式传输过程中断开 + ReasoningEffort *string // 图片生成计费字段(图片生成模型使用) ImageCount int // 生成的图片数量 @@ -572,6 +614,8 @@ type ForwardResult struct { ImageOutputSizes []string ImageSizeSource string ImageSizeBreakdown map[string]int + SearchCount int + AudioUsage *AudioUsage } // GatewayFailureStage identifies which request stage failed. The zero value is @@ -614,6 +658,7 @@ type UpstreamFailoverError struct { ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息 ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 + RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage Scope GatewayFailureScope @@ -665,6 +710,11 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou if failoverErr == nil || !failoverErr.RetryableOnSameAccount { return } + // 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉, + // 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。 + if failoverErr.RequestScopedTransient { + return + } // 根据状态码选择封禁策略 switch failoverErr.StatusCode { case http.StatusBadRequest: @@ -873,6 +923,42 @@ func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL) } +// bindGatewayStickySessionDuringSelection preserves the normal eager sticky +// behavior unless a profit gate is installed. Profit-controlled requests bind +// only after the terminal post-slot check, otherwise a rejected candidate could +// overwrite a healthy pre-existing sticky binding. +func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { + if gatewayProfitControlGateActive(ctx) { + return nil + } + return s.BindStickySession(ctx, groupID, sessionHash, accountID) +} + +// BindStickySessionAfterProfitAdmission records a terminally admitted +// account. Without a profit gate it preserves the pre-existing eager binding +// behavior at the handler bind points. With a gate it never replaces a +// different binding that already exists: a temporarily ineligible sticky +// account remains bound and automatically becomes eligible again if its +// account rate recovers. +func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { + if sessionHash == "" || accountID <= 0 || s.cache == nil { + return nil + } + if !gatewayProfitControlGateActive(ctx) { + return s.BindStickySession(ctx, groupID, sessionHash, accountID) + } + existingAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash) + if err != nil && !errors.Is(err, ErrStickySessionNotFound) { + // 读失败时无法判断既有绑定,保守跳过而不是冒着覆盖健康绑定的风险写入。 + slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err) + return nil + } + if existingAccountID > 0 && existingAccountID != accountID { + return nil + } + return s.BindStickySession(ctx, groupID, sessionHash, accountID) +} + // GetCachedSessionAccountID retrieves the account ID bound to a sticky session. // Returns 0 if no binding exists or on error. func (s *GatewayService) GetCachedSessionAccountID(ctx context.Context, groupID *int64, sessionHash string) (int64, error) { @@ -1160,6 +1246,15 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s return accessToken, "oauth", nil } + // Grok OAuth: prefer access_token from credentials (background refresher keeps it warm). + if account.Platform == PlatformGrok && account.Type == AccountTypeOAuth { + accessToken := account.GetGrokAccessToken() + if accessToken == "" { + return "", "", errors.New("grok access_token not found in credentials") + } + return accessToken, "oauth", nil + } + // 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取 accessToken := account.GetCredential("access_token") if accessToken == "" { @@ -1171,6 +1266,78 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (s // GetAvailableModels returns the list of models available for a group // It aggregates model_mapping keys from all schedulable accounts in the group + +// DoGrokNativeResponsesJSON POSTs a non-streaming Responses body to the account's +// Grok upstream and returns the raw JSON body. Used by /v1/web_search. +// Gin-free: UA is always the pinned Grok CLI identity (resolveGrokUpstreamUserAgent ignores inbound). +func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account *Account, body []byte) ([]byte, error) { + if s == nil || s.httpUpstream == nil { + return nil, errors.New("http upstream not configured") + } + if account == nil { + return nil, errors.New("account is required") + } + if !account.IsGrok() { + return nil, errors.New("grok account required") + } + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + // Credential/token failures should try the next Grok account in the pool. + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusUnauthorized, + Reason: GatewayFailureReason("grok_search_token"), + } + } + targetURL, err := buildGrokResponsesURL(account, nil, s.settingService) + if err != nil { + return nil, err + } + if json.Valid(body) { + if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model == "" { + if patched, patchErr := sjson.SetBytes(body, "model", xai.DefaultTextModel); patchErr == nil { + body = patched + } + } + } + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build grok responses request: %w", err) + } + upstreamReq.Header.Set("Authorization", "Bearer "+token) + upstreamReq.Header.Set("Content-Type", "application/json") + upstreamReq.Header.Set("Accept", "application/json") + upstreamReq.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) + applyGrokCLIHeaders(upstreamReq.Header) + account.ApplyHeaderOverrides(upstreamReq.Header) + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, Reason: GatewayFailureReason("grok_search_transport")} + } + defer func() { _ = resp.Body.Close() }() + respBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if readErr != nil { + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + Reason: GatewayFailureReason("grok_search_read"), + } + } + if resp.StatusCode >= 400 { + msg := string(respBytes) + if len(msg) > 200 { + msg = msg[:200] + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPaymentRequired || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBytes} + } + return nil, fmt.Errorf("grok upstream %d: %s", resp.StatusCode, msg) + } + return respBytes, nil +} + func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { cacheKey := modelsListCacheKey(groupID, platform) if s.modelsListCache != nil { diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index a32c007e03..5a9fedf964 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -647,7 +647,51 @@ type streamingResult struct { clientDisconnect bool // 客户端是否在流式传输过程中断开 } +// hasObservedTokens 报告流式过程中是否已观测到任何上游计量的 token。 +func (u *ClaudeUsage) hasObservedTokens() bool { + if u == nil { + return false + } + return u.InputTokens > 0 || u.OutputTokens > 0 || + u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0 || + u.CacheCreation5mTokens > 0 || u.CacheCreation1hTokens > 0 || + u.ImageOutputTokens > 0 +} + +// partialStreamUsageResult 在流式转发中途出错时,把已观测到 usage 的部分结果包装为 +// ForwardResult(与错误一起返回给 handler 记录)。上游一旦下发过 message_start, +// input/cache token 就已计量,直接丢弃会让请求完全漏记漏计费(issue #5148)。 +// 无已观测 usage 时返回 nil。 +// +// 不变式:UpstreamFailoverError 必须保持 result=nil——failover 重试成功后按成功请求 +// 计费,若同时返回部分 usage 会造成双重计费,此处显式拦截兜底。 +func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult { + if streamResult == nil || !streamResult.usage.hasObservedTokens() { + return nil + } + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) { + return nil + } + return &ForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: *streamResult.usage, + Model: model, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: streamResult.firstTokenMs, + ClientDisconnect: streamResult.clientDisconnect, + } +} + func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } // 更新5h窗口状态 s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) @@ -843,6 +887,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http } eventType, _ := event["type"].(string) + observer.ObserveAnthropic([]byte(dataLine)) if eventName == "" { eventName = eventType } @@ -1338,6 +1383,11 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveAnthropic(body) // 解析usage var response struct { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 51b8031fc4..a5998f3595 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -42,6 +42,7 @@ type RecordUsageInput struct { User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 + PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义 InboundEndpoint string // 入站端点(客户端请求路径) UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent @@ -203,6 +204,13 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill } func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string { + // Forced durable money-event IDs must win over client/local context IDs so + // standalone web_search / async video cannot collapse under a reused client id. + if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" { + if isForcedUsageBillingRequestID(requestID) { + return requestID + } + } if ctx != nil { if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { return "client:" + strings.TrimSpace(clientRequestID) @@ -217,6 +225,40 @@ func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) return "generated:" + generateRequestID() } +func isForcedUsageBillingRequestID(requestID string) bool { + id := strings.TrimSpace(requestID) + return strings.HasPrefix(id, "web_search:") || + strings.HasPrefix(id, "grok-video:") || + strings.HasPrefix(id, "grok_audio:") || + strings.HasPrefix(id, "grok_realtime:") +} + +// StableGrokAudioBillingRequestID is the durable usage_logs / dedup key for one +// voice HTTP call (TTS/STT). Prefer an upstream request id when present. +func StableGrokAudioBillingRequestID(upstreamRequestID string) string { + upstreamRequestID = strings.TrimSpace(upstreamRequestID) + if strings.HasPrefix(upstreamRequestID, "grok_audio:") { + return upstreamRequestID + } + if upstreamRequestID == "" { + upstreamRequestID = generateRequestID() + } + return "grok_audio:" + upstreamRequestID +} + +// StableGrokRealtimeBillingRequestID is the durable usage_logs / dedup key for +// one realtime WebSocket session. +func StableGrokRealtimeBillingRequestID(sessionID string) string { + sessionID = strings.TrimSpace(sessionID) + if strings.HasPrefix(sessionID, "grok_realtime:") { + return sessionID + } + if sessionID == "" { + sessionID = generateRequestID() + } + return "grok_realtime:" + sessionID +} + func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string { if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" { return payloadHash @@ -569,6 +611,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu User: input.User, Account: input.Account, Subscription: input.Subscription, + PricingAt: input.PricingAt, InboundEndpoint: input.InboundEndpoint, UpstreamEndpoint: input.UpstreamEndpoint, UserAgent: input.UserAgent, @@ -589,6 +632,7 @@ type RecordUsageLongContextInput struct { User *User Account *Account Subscription *UserSubscription // 可选:订阅信息 + PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义 InboundEndpoint string // 入站端点(客户端请求路径) UpstreamEndpoint string // 上游端点(标准化后的上游路径) UserAgent string // 请求的 User-Agent @@ -612,6 +656,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * User: input.User, Account: input.Account, Subscription: input.Subscription, + PricingAt: input.PricingAt, InboundEndpoint: input.InboundEndpoint, UpstreamEndpoint: input.UpstreamEndpoint, UserAgent: input.UserAgent, @@ -635,6 +680,7 @@ type recordUsageCoreInput struct { User *User Account *Account Subscription *UserSubscription + PricingAt time.Time InboundEndpoint string UpstreamEndpoint string UserAgent string @@ -647,6 +693,80 @@ type recordUsageCoreInput struct { ChannelUsageFields } +// responseModelBillingCostEpsilon 吸收两次成本计算之间的浮点末位误差, +// 避免同价模型因浮点误差被判成"更贵"而白白放弃采纳。 +const responseModelBillingCostEpsilon = 1e-12 + +// responseModelBillingDeclaration 返回可用于计费的上游响应模型;返回空字符串表示 +// 必须沿用基线计费模型。两条计费主干(Anthropic 系 / OpenAI 系)共用本准入判断。 +// +// 渠道把 billing_model_source 设为 response_model,等于把"按哪个模型计价"的一部分 +// 决定权交给上游,因此准入条件必须收紧: +// - 只在渠道显式开启该模式时生效,其余模式一律不看响应模型; +// - 一次请求内出现过互相冲突的模型声明时不采纳(无法确定上游究竟服务了哪个模型); +// - 图片 / 视频 / 网页搜索 / 语音 / 搜索附加费这类按次按量计费的请求不采纳:它们按张、 +// 按秒、按次定价,与本模式的 token 定价准入检查不是同一套价格表,混用会让一个只验过 +// token 价的模型名去决定媒体单价。新增按次计费形态时必须同步扩这个入参。 +// +// 调用方还必须额外满足两条:模型能被价格表确定性识别(见 +// hasIdentifiedResponseModelPricing / hasIdentifiedOpenAIResponsePricing),以及通过 +// responseModelBillingAdoptable 的成本准入。 +func responseModelBillingDeclaration(source, responseModel string, conflict, mediaBilled bool) string { + if source != BillingModelSourceResponse || conflict || mediaBilled { + return "" + } + return strings.TrimSpace(responseModel) +} + +// responseModelBillingAdoptable 判定按响应模型重算出的成本能否取代基线成本。 +// 三条不变式,任一不满足都必须沿用基线(即开启本模式前的既有行为): +// +// 1. 不得更贵——上游声明永远不能抬高用户费用;epsilon 吸收两次计算之间的浮点末位误差。 +// 2. 不得把一笔本应计费的请求归零。价格表里存在把 token 价显式写成 0 的条目 +// (TokenPricingAbsent 只在 input/output 价**都缺失**时才为真,显式 0 算"有价"因而 +// 能通过确定性识别那道门),放任归零等于让上游自报一个免费模型名就能白嫖。 +// 基线本身就是 0 时不受影响,采纳与否都不改变金额。 +// 3. 不得把计费从管理员显式配置的渠道定价切到全局价格表。渠道定价查表只做精确键与 +// 前缀通配、**不剥日期后缀**,而全局价格表的确定性识别**会剥** 8 位日期后缀;上游 +// 普遍自报带日期的模型 ID(如 claude-opus-4-5-20251101),若允许跨源比较,渠道加价 +// 会被这类自报名字静默绕过。管理员若确实想让降级目标享受折扣,为它显式配一条渠道 +// 定价即可——那是一次可审计的显式授权。 +func responseModelBillingAdoptable(baseline, response *CostBreakdown, baselineChannelPriced, responseChannelPriced bool) bool { + if baseline == nil || response == nil { + return false + } + if response.TotalCost > baseline.TotalCost+responseModelBillingCostEpsilon { + return false + } + if response.TotalCost <= 0 && baseline.TotalCost > 0 { + return false + } + return !baselineChannelPriced || responseChannelPriced +} + +// logResponseModelBillingApplied 记录一次实际生效的响应模型计费切换。 +// 本模式下的少收由上游声明驱动,必须留下可审计痕迹;计费基准未变时不记录,避免刷屏。 +func logResponseModelBillingApplied(component string, account *Account, requestID, baselineModel, responseModel string, baselineCost, responseCost *CostBreakdown) { + baselineModel = strings.TrimSpace(baselineModel) + responseModel = strings.TrimSpace(responseModel) + if strings.EqualFold(baselineModel, responseModel) { + return + } + attrs := []any{ + "component", component, + "request_id", strings.TrimSpace(requestID), + "baseline_model", baselineModel, + "response_model", responseModel, + } + if baselineCost != nil && responseCost != nil { + attrs = append(attrs, "baseline_cost", baselineCost.TotalCost, "billed_cost", responseCost.TotalCost) + } + if account != nil { + attrs = append(attrs, "platform", account.Platform, "account_id", account.ID) + } + slog.Info("billing.response_model_applied", attrs...) +} + // recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。 // LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。 func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error { @@ -685,7 +805,11 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now()) + pricingAt := input.PricingAt + if pricingAt.IsZero() { + pricingAt = timezone.Now() + } + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, pricingAt) // 确定计费模型 concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) @@ -715,6 +839,27 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 计算费用 cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) + // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + if responseModel := responseModelBillingDeclaration( + input.BillingModelSource, + result.UpstreamResponseModel, + result.UpstreamResponseModelConflict, + 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) + baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil + if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, + // 因此这里不改写它,改由日志记录实际生效的计费基准。 + logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) + cost = responseCost + } + } + } // 判断计费方式:订阅模式 vs 余额模式 isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -777,6 +922,8 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage }, s.billingDeps(), s.usageBillingRepo) if billingErr != nil { + usageLog.ActualCost = 0 + writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") return billingErr } writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway") @@ -802,8 +949,29 @@ func (s *GatewayService) calculateRecordUsageCost( return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } - // Token 计费 - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + // Voice audio (TTS / STT / realtime) when present on the forward result. + if result.AudioUsage != nil { + cfg := groupAudioPriceConfigFromAPIKey(apiKey) + return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, multiplier) + } + + // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 + tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts) + if result.SearchCount > 0 { + price := groupSearchPricePer1kFromAPIKey(apiKey) + if price != nil && *price == 0 { + logger.LegacyPrintf("service.gateway", "[Billing] search_price_per_1k explicit 0; search free group_model=%s count=%d", billingModel, result.SearchCount) + } + searchCost := s.billingService.CalculateSearchCost(result.SearchCount, price, multiplier) + if searchCost != nil && (searchCost.TotalCost > 0 || searchCost.ActualCost > 0) { + if tokenCost == nil { + return searchCost + } + tokenCost.TotalCost += searchCost.TotalCost + tokenCost.ActualCost += searchCost.ActualCost + } + } + return tokenCost } // compositeBillableModel 决定 composite 分组请求的计费模型:来源覆盖把计费模型 @@ -856,6 +1024,22 @@ func (s *GatewayService) hasResolvableTokenPricing(ctx context.Context, model st return err == nil } +// hasIdentifiedResponseModelPricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 +// 与 hasResolvableTokenPricing 的区别是刻意更严:只接受管理员为该模型显式配置的 +// 渠道定价,或价格表中能被确定性识别的条目;不接受按子串猜出来的系列兜底价。 +// 详见 responseModelBillingDeclaration 的说明。 +func (s *GatewayService) hasIdentifiedResponseModelPricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { + if strings.TrimSpace(model) == "" { + return false, false + } + if s.resolveChannelPricing(ctx, model, apiKey) != nil { + return true, true + } + return s.billingService.HasIdentifiedTokenPricing(model), false +} + // resolveChannelPricing 检查指定模型是否存在渠道级别定价。 // 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。 func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing { @@ -979,6 +1163,16 @@ func (s *GatewayService) buildRecordUsageLog( ) *UsageLog { durationMs := int(result.Duration.Milliseconds()) requestID := resolveUsageBillingRequestID(ctx, result.RequestID) + sentModel := upstreamSentModel(result.Model, result.UpstreamModel) + if result.UpstreamResponseModelConflict { + slog.Warn("upstream_response_model_conflict", + "platform", account.Platform, + "account_id", account.ID, + "request_id", requestID, + "sent_model", sentModel, + "selected_response_model", strings.TrimSpace(result.UpstreamResponseModel), + ) + } usageLog := &UsageLog{ UserID: user.ID, APIKeyID: apiKey.ID, @@ -987,6 +1181,8 @@ func (s *GatewayService) buildRecordUsageLog( Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), + UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), + UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/gateway_usage_billing_request_id_test.go b/backend/internal/service/gateway_usage_billing_request_id_test.go new file mode 100644 index 0000000000..78a64cb338 --- /dev/null +++ b/backend/internal/service/gateway_usage_billing_request_id_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "context" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +func TestResolveUsageBillingRequestID_ForcedWebSearchBeatsClientID(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), ctxkey.ClientRequestID, "client-shared-id") + got := resolveUsageBillingRequestID(ctx, "web_search:uuid-1") + require.Equal(t, "web_search:uuid-1", got) +} + +func TestResolveUsageBillingRequestID_ClientWinsOverPlainUpstream(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), ctxkey.ClientRequestID, "client-shared-id") + got := resolveUsageBillingRequestID(ctx, "resp_abc") + require.Equal(t, "client:client-shared-id", got) +} + +func TestIsForcedUsageBillingRequestID(t *testing.T) { + t.Parallel() + require.True(t, isForcedUsageBillingRequestID("web_search:x")) + require.True(t, isForcedUsageBillingRequestID("grok-video:task-1")) + require.True(t, isForcedUsageBillingRequestID("grok_audio:up-1")) + require.True(t, isForcedUsageBillingRequestID("grok_realtime:sess-1")) + require.False(t, isForcedUsageBillingRequestID("resp_abc")) +} + +func TestStableGrokAudioBillingRequestID(t *testing.T) { + t.Parallel() + require.Equal(t, "grok_audio:up-1", StableGrokAudioBillingRequestID("up-1")) + require.Equal(t, "grok_audio:up-1", StableGrokAudioBillingRequestID("grok_audio:up-1")) + got := StableGrokAudioBillingRequestID("") + require.True(t, strings.HasPrefix(got, "grok_audio:")) + require.Greater(t, len(got), len("grok_audio:")) +} + +func TestStableGrokRealtimeBillingRequestID(t *testing.T) { + t.Parallel() + require.Equal(t, "grok_realtime:s1", StableGrokRealtimeBillingRequestID("s1")) + require.Equal(t, "grok_realtime:s1", StableGrokRealtimeBillingRequestID("grok_realtime:s1")) + got := StableGrokRealtimeBillingRequestID("") + require.True(t, strings.HasPrefix(got, "grok_realtime:")) +} + +func TestResolveUsageBillingRequestID_ForcedGrokAudioBeatsClientID(t *testing.T) { + t.Parallel() + ctx := context.WithValue(context.Background(), ctxkey.ClientRequestID, "client-shared-id") + got := resolveUsageBillingRequestID(ctx, StableGrokAudioBillingRequestID("up-9")) + require.Equal(t, "grok_audio:up-9", got) +} diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index d766dc9ab7..e76fbcee15 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -215,7 +215,9 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( if s.rateLimitService != nil { policy = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) } - if policy != ErrorPolicyTempUnscheduled { + // 与 messages 兼容层一致:只有 None / Matched 才走账号状态处理。 + // Skipped(池模式、或自定义错误码未命中)与 TempUnscheduled 已由策略层裁决完毕。 + if policy == ErrorPolicyNone || policy == ErrorPolicyMatched { s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) } evBody := unwrapIfNeeded(account.Type == AccountTypeOAuth, respBody) @@ -322,9 +324,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF if clientStream { action = "streamGenerateContent" } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if clientStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, clientStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -389,9 +391,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, useUpstreamStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index e71bd141b5..46024077a5 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -489,6 +489,105 @@ func TestHandleGeminiUpstreamError_GoogleOneCapacityExhaustedUsesTierCooldown(t require.True(t, repo.lastRateLimitReset.Before(after.Add(5*time.Minute).Add(2*time.Second))) } +// --------------------------------------------------------------------------- +// TestHandleGeminiUpstreamError_PoolMode429 — 池模式账号的 429 不写账号级限流。 +// +// 429 的标记点在重试循环内(handleClaudeCompat / forwardNativeGemini / +// chat completions 三条路径),先于 CheckErrorPolicy 执行,池模式豁免只能落在 +// handleGeminiUpstreamError 自身;否则一次上游 429 会把账号锁到 PST 午夜, +// 即便重试已经成功返回客户端。 +// --------------------------------------------------------------------------- + +func TestHandleGeminiUpstreamError_PoolMode429(t *testing.T) { + // 中转上游的真实 429 文案:不含 "per day",也没有 quotaResetDelay, + // 解析失败后 apikey 账号会落到 PST 午夜兜底。 + body := []byte(`{"error":{"code":429,"message":"You have exhausted your capacity on this model. Your quota will reset after 6h53m10s."}}`) + + tests := []struct { + name string + account *Account + expectRateLimited bool + }{ + { + name: "pool_mode_apikey_stays_in_pool", + account: &Account{ + ID: 600, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"pool_mode": true}, + }, + expectRateLimited: false, + }, + { + name: "custom_error_codes_hit_overrides_pool_mode", + account: &Account{ + ID: 601, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, + expectRateLimited: true, + }, + { + name: "custom_error_codes_miss_skips", + account: &Account{ + ID: 602, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(500)}, + }, + }, + expectRateLimited: false, + }, + { + name: "non_pool_apikey_still_rate_limited", + account: &Account{ + ID: 603, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + }, + expectRateLimited: true, + }, + { + name: "oauth_account_ignores_pool_mode_flag", + account: &Account{ + ID: 604, + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: map[string]any{"pool_mode": true}, + }, + expectRateLimited: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil), + } + + svc.handleGeminiUpstreamError(context.Background(), tt.account, http.StatusTooManyRequests, http.Header{}, body) + + if !tt.expectRateLimited { + require.Zero(t, repo.rateLimitCalls, "池模式账号不应被标记账号级限流") + return + } + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, tt.account.ID, repo.lastRateLimitID) + require.True(t, repo.lastRateLimitReset.After(time.Now())) + }) + } +} + type geminiErrorPolicyRepo struct { mockAccountRepoForGemini setErrorCalls int diff --git a/backend/internal/service/gemini_image_output_accounting.go b/backend/internal/service/gemini_image_output_accounting.go new file mode 100644 index 0000000000..948a2d4aee --- /dev/null +++ b/backend/internal/service/gemini_image_output_accounting.go @@ -0,0 +1,132 @@ +package service + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// geminiImageOutputCounterKey 是请求级内联图片计数器挂在 gin.Context 上的键。 +const geminiImageOutputCounterKey = "gemini_image_output_counter" + +// geminiImageOutputCounter 记录一次转发里 Gemini 上游真正回吐的内联图片数量。 +// +// 取「单个 payload 内的最大值」而不是累加:Gemini 兼容上游的 SSE 分片可能是 +// 累积式的(同一份内容在后续 chunk 里重复整段回来——本文件同目录的 +// computeGeminiTextDelta 就是为此存在的),逐 chunk 累加会把同一张图算很多次。 +// 计费上宁可少算不可多算,所以用 max 兜底: +// - 非流式:整份响应体只观测一次,max 即真实张数; +// - 累积式流:最后一个 chunk 含全部图片,max 仍是真实张数; +// - 增量式流且多图分散在不同 chunk:会低估到 1,与改动前的模型名启发式同值, +// 不构成回退。 +type geminiImageOutputCounter struct { + count int +} + +// beginGeminiImageOutputObservation 在每次 Forward 开头重置计数器。 +// failover 会拿同一个 gin.Context 重跑转发,不重置就会把上一个账号的图数带进来。 +func beginGeminiImageOutputObservation(c *gin.Context) *geminiImageOutputCounter { + if c == nil { + return nil + } + counter := &geminiImageOutputCounter{} + c.Set(geminiImageOutputCounterKey, counter) + return counter +} + +func geminiImageOutputCounterFromContext(c *gin.Context) *geminiImageOutputCounter { + if c == nil { + return nil + } + value, ok := c.Get(geminiImageOutputCounterKey) + if !ok { + return nil + } + counter, _ := value.(*geminiImageOutputCounter) + return counter +} + +// observeGeminiImageOutputs 观测一段上游响应(整份或单个 chunk)里的内联图片。 +// 调用点与 upstreamResponseModelObserver.ObserveGemini 一一对应——那里拿得到 +// 解包后的上游响应体,这里需要的是同一份字节。 +func observeGeminiImageOutputs(c *gin.Context, payload []byte) { + counter := geminiImageOutputCounterFromContext(c) + if counter == nil { + return + } + if count := countGeminiInlineImageOutputs(payload); count > counter.count { + counter.count = count + } +} + +func observedGeminiImageOutputs(c *gin.Context) int { + counter := geminiImageOutputCounterFromContext(c) + if counter == nil { + return 0 + } + return counter.count +} + +// resolveGeminiImageCount 决定本次请求按几张图计费。 +// +// 优先用上游真正返回的内联图片数:走 GeminiMessagesCompatService 的账号多是 +// API Key + 自定义模型映射,客户端请求名和上游模型名都可能是站长自取的别名 +// (issue #5358 里的 nana-banana-2),isImageGenerationModel 的白名单必然判不出, +// 于是 ImageCount=0,calculateRecordUsageCost 整条按次计费分支不触发, +// 生图请求全部记 $0。 +// +// 只有响应里数不出图时(例如上游用 fileData 引用而非 inlineData 回图,或聚合 +// 函数丢掉了图片 part)才退回既有的模型名启发式,保证老行为不回退;这里额外 +// 也认映射后的上游模型名,与 shouldSkipCodexPlanGatedImageModelCooldown 对 +// requestedModel / modelKey 双取的口径一致。 +func resolveGeminiImageCount(c *gin.Context, originalModel, mappedModel string) int { + if observed := observedGeminiImageOutputs(c); observed > 0 { + return observed + } + if isImageGenerationModel(originalModel) || isImageGenerationModel(mappedModel) { + return 1 + } + return 0 +} + +// countGeminiInlineImageOutputs 统计一段 Gemini 响应 JSON 里的内联图片 part。 +// Gemini REST 回 camelCase 的 inlineData,官方 SDK 与部分中转会回 snake_case +// 的 inline_data,两种都要认。 +func countGeminiInlineImageOutputs(payload []byte) int { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return 0 + } + count := 0 + gjson.GetBytes(payload, "candidates").ForEach(func(_, candidate gjson.Result) bool { + candidate.Get("content.parts").ForEach(func(_, part gjson.Result) bool { + if geminiPartIsInlineImage(part) { + count++ + } + return true + }) + return true + }) + return count +} + +func geminiPartIsInlineImage(part gjson.Result) bool { + inline := part.Get("inlineData") + if !inline.Exists() { + inline = part.Get("inline_data") + } + if !inline.Exists() { + return false + } + + mimeType := inline.Get("mimeType") + if !mimeType.Exists() { + mimeType = inline.Get("mime_type") + } + if !isGeminiInlineImageMIMEType(strings.ToLower(strings.TrimSpace(mimeType.String()))) { + return false + } + + // 只认真的带上了 base64 数据的 part,空壳 part 不计费。 + return strings.TrimSpace(inline.Get("data").String()) != "" +} diff --git a/backend/internal/service/gemini_image_output_accounting_test.go b/backend/internal/service/gemini_image_output_accounting_test.go new file mode 100644 index 0000000000..eb6bb2bc69 --- /dev/null +++ b/backend/internal/service/gemini_image_output_accounting_test.go @@ -0,0 +1,204 @@ +//go:build unit + +package service + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const geminiTestPNG = "iVBORw0KGgoAAAANSUhEUg==" + +func newGeminiImageTestContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, + "/v1beta/models/nana-banana-2:generateContent", strings.NewReader("{}")) + return c +} + +func geminiImageResponse(parts string) string { + return `{"candidates":[{"content":{"role":"model","parts":[` + parts + `]},"finishReason":"STOP"}]}` +} + +func TestCountGeminiInlineImageOutputs(t *testing.T) { + cases := []struct { + name string + payload string + want int + }{ + { + name: "camelCase inlineData", + payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`), + want: 1, + }, + { + // 官方 SDK 与部分中转把字段回成 snake_case。 + name: "snake_case inline_data", + payload: geminiImageResponse(`{"inline_data":{"mime_type":"image/png","data":"` + geminiTestPNG + `"}}`), + want: 1, + }, + { + name: "text and image mixed", + payload: geminiImageResponse(`{"text":"here you go"},` + + `{"inlineData":{"mimeType":"image/jpeg","data":"` + geminiTestPNG + `"}}`), + want: 1, + }, + { + name: "multiple images", + payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}},` + + `{"inlineData":{"mimeType":"image/webp","data":"` + geminiTestPNG + `"}}`), + want: 2, + }, + { + name: "uppercase mime type", + payload: geminiImageResponse(`{"inlineData":{"mimeType":"IMAGE/PNG","data":"` + geminiTestPNG + `"}}`), + want: 1, + }, + { + name: "text only", + payload: geminiImageResponse(`{"text":"no image here"}`), + want: 0, + }, + { + // 非图片的内联附件(例如音频)不能按图片计费。 + name: "non image mime type", + payload: geminiImageResponse(`{"inlineData":{"mimeType":"audio/mpeg","data":"` + geminiTestPNG + `"}}`), + want: 0, + }, + { + name: "empty data is not billable", + payload: geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":""}}`), + want: 0, + }, + {name: "empty payload", payload: "", want: 0}, + {name: "invalid json", payload: "not-json", want: 0}, + {name: "error response", payload: `{"error":{"code":429,"message":"quota"}}`, want: 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, countGeminiInlineImageOutputs([]byte(tc.payload))) + }) + } +} + +// 累积式 SSE 会把同一张图在后续 chunk 里整段重发,逐 chunk 累加会重复计费。 +// 计数器取单个 payload 内的最大值,正是为了挡住这一点。 +func TestObserveGeminiImageOutputs_CumulativeChunksDoNotDoubleCount(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + + oneImage := geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`) + for range 4 { + observeGeminiImageOutputs(c, []byte(oneImage)) + } + + require.Equal(t, 1, observedGeminiImageOutputs(c)) +} + +func TestObserveGeminiImageOutputs_KeepsLargestChunk(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + + observeGeminiImageOutputs(c, []byte(geminiImageResponse(`{"text":"working"}`))) + observeGeminiImageOutputs(c, []byte(geminiImageResponse( + `{"inlineData":{"mimeType":"image/png","data":"`+geminiTestPNG+`"}},`+ + `{"inlineData":{"mimeType":"image/png","data":"`+geminiTestPNG+`"}}`))) + // 收尾 chunk 只带 usageMetadata,不能把已数到的张数抹掉。 + observeGeminiImageOutputs(c, []byte(`{"usageMetadata":{"promptTokenCount":9}}`)) + + require.Equal(t, 2, observedGeminiImageOutputs(c)) +} + +// failover 会拿同一个 gin.Context 重跑 Forward,计数器必须按次重置, +// 否则失败账号已经回吐的图会被叠加到成功账号的账单上。 +func TestBeginGeminiImageOutputObservation_ResetsPerForward(t *testing.T) { + c := newGeminiImageTestContext(t) + oneImage := []byte(geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`)) + + beginGeminiImageOutputObservation(c) + observeGeminiImageOutputs(c, oneImage) + require.Equal(t, 1, observedGeminiImageOutputs(c)) + + beginGeminiImageOutputObservation(c) + require.Equal(t, 0, observedGeminiImageOutputs(c)) + observeGeminiImageOutputs(c, oneImage) + require.Equal(t, 1, observedGeminiImageOutputs(c)) +} + +// issue #5358:自定义模型名(客户端名与上游映射名都不在白名单里)走 Gemini 原生 +// generateContent 生图,改动前 ImageCount 恒为 0,calculateRecordUsageCost 的按次 +// 计费分支整条不触发,四次生图全部记 $0。 +func TestResolveGeminiImageCount(t *testing.T) { + oneImage := []byte(geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`)) + textOnly := []byte(geminiImageResponse(`{"text":"hello"}`)) + + t.Run("custom model name bills by observed images", func(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + observeGeminiImageOutputs(c, oneImage) + + require.False(t, isImageGenerationModel("nana-banana-2"), "前置条件:白名单判不出自定义名") + require.Equal(t, 1, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2")) + }) + + t.Run("falls back to requested model name", func(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + observeGeminiImageOutputs(c, textOnly) + + require.Equal(t, 1, resolveGeminiImageCount(c, "gemini-3-pro-image-preview", "gemini-3-pro-image-preview")) + }) + + t.Run("falls back to mapped upstream model name", func(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + observeGeminiImageOutputs(c, textOnly) + + require.Equal(t, 1, resolveGeminiImageCount(c, "my-image-alias", "gemini-2.5-flash-image")) + }) + + t.Run("text model stays unbilled", func(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + observeGeminiImageOutputs(c, textOnly) + + require.Equal(t, 0, resolveGeminiImageCount(c, "gemini-2.5-pro", "gemini-2.5-pro")) + }) + + t.Run("no counter on context degrades to name heuristic", func(t *testing.T) { + c := newGeminiImageTestContext(t) + require.Equal(t, 0, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2")) + require.Equal(t, 1, resolveGeminiImageCount(c, "gemini-3-pro-image", "gemini-3-pro-image")) + }) +} + +// 端到端守住接线:/v1beta/models/{model}:generateContent 的非流式响应体 +// 必须真的喂进计数器,否则上面的单测全绿而线上依然记 $0。 +func TestHandleNativeNonStreamingResponse_FeedsImageCounter(t *testing.T) { + c := newGeminiImageTestContext(t) + beginGeminiImageOutputObservation(c) + + body := geminiImageResponse(`{"inlineData":{"mimeType":"image/png","data":"` + geminiTestPNG + `"}}`) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + + svc := &GeminiMessagesCompatService{} + usage, err := svc.handleNativeNonStreamingResponse(c, resp, false) + require.NoError(t, err) + require.NotNil(t, usage) + + require.Equal(t, 1, observedGeminiImageOutputs(c)) + require.Equal(t, 1, resolveGeminiImageCount(c, "nana-banana-2", "nana-banana-2")) +} diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 1752e74bb9..70dee027c1 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -581,6 +581,8 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont } func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) + beginGeminiImageOutputObservation(c) startTime := time.Now() var req struct { @@ -638,9 +640,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if req.Stream { action = "streamGenerateContent" } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if req.Stream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, req.Stream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -712,9 +714,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, action) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, action, useUpstreamStream) + if err != nil { + return nil, "", err } restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) @@ -1072,6 +1074,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream") } collectedBytes, _ := json.Marshal(collected) + upstreamResponseModelObserverFromContext(c).ObserveGemini(collectedBytes) + observeGeminiImageOutputs(c, collectedBytes) claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes, false) c.JSON(http.StatusOK, claudeResp) usage = usageObj2 @@ -1087,24 +1091,23 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } // 图片生成计费 - imageCount := 0 imageInputSize := s.extractImageInputSize(body) imageSize := normalizeOpenAIImageSizeTier(imageInputSize) - if isImageGenerationModel(originalModel) { - imageCount = 1 - } + imageCount := resolveGeminiImageCount(c, originalModel, mappedModel) return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: req.Stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: req.Stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -1117,6 +1120,8 @@ func isGeminiSignatureRelatedError(respBody []byte) bool { } func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte) (*ForwardResult, error) { + beginUpstreamResponseModelObservation(c) + beginGeminiImageOutputObservation(c) startTime := time.Now() if strings.TrimSpace(originalModel) == "" { @@ -1181,9 +1186,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, upstreamAction) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, upstreamAction, useUpstreamStream) + if err != nil { + return nil, "", err } upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) @@ -1249,9 +1254,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, "", err } - fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(normalizedBaseURL, "/"), mappedModel, upstreamAction) - if useUpstreamStream { - fullURL += "?alt=sse" + fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, mappedModel, upstreamAction, useUpstreamStream) + if err != nil { + return nil, "", err } upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body)) @@ -1603,6 +1608,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to read upstream stream") } b, _ := json.Marshal(collected) + upstreamResponseModelObserverFromContext(c).ObserveGemini(b) + observeGeminiImageOutputs(c, b) c.Data(http.StatusOK, "application/json", b) usage = usageObj } else { @@ -1619,24 +1626,23 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. } // 图片生成计费 - imageCount := 0 imageInputSize := s.extractImageInputSize(body) imageSize := normalizeOpenAIImageSizeTier(imageInputSize) - if isImageGenerationModel(originalModel) { - imageCount = 1 - } + imageCount := resolveGeminiImageCount(c, originalModel, mappedModel) return &ForwardResult{ - RequestID: requestID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: stream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ImageCount: imageCount, - ImageSize: imageSize, - ImageInputSize: imageInputSize, + RequestID: requestID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: stream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, + ImageInputSize: imageInputSize, }, nil } @@ -1999,6 +2005,12 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context, if err != nil { return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(unwrappedBody) + observeGeminiImageOutputs(c, unwrappedBody) var geminiResp map[string]any if err := json.Unmarshal(unwrappedBody, &geminiResp); err != nil { @@ -2082,6 +2094,12 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re if err != nil { continue } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(unwrappedBytes) + observeGeminiImageOutputs(c, unwrappedBytes) var geminiResp map[string]any if err := json.Unmarshal(unwrappedBytes, &geminiResp); err != nil { @@ -2583,6 +2601,12 @@ func (s *GeminiMessagesCompatService) handleNativeNonStreamingResponse(c *gin.Co respBody = unwrappedBody } } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveGemini(respBody) + observeGeminiImageOutputs(c, respBody) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -2631,6 +2655,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte reader := bufio.NewReader(resp.Body) usage := &ClaudeUsage{} + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } var firstTokenMs *int for { @@ -2661,6 +2689,8 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte if u := extractGeminiUsage(rawBytes); u != nil { usage = u } + observer.ObserveGemini(rawBytes) + observeGeminiImageOutputs(c, rawBytes) if firstTokenMs == nil { ms := int(time.Since(startTime).Milliseconds()) @@ -2701,10 +2731,13 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac if account == nil { return nil, errors.New("account is nil") } - path = strings.TrimSpace(path) - if path == "" || !strings.HasPrefix(path, "/") { + // path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验, + // 见 upstream_path_guard.go。 + sanitizedPath, ok := sanitizedUpstreamPathSuffix(path) + if !ok || sanitizedPath == "" { return nil, errors.New("invalid path") } + path = sanitizedPath baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) @@ -2928,6 +2961,11 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont if statusCode != 429 { return } + // 池模式账号不写账号级限流:账号留在池内,由 failover / 同号重试消化 429。 + // 自定义错误码优先级高于池模式,开启后仍按其命中结果标记。 + if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() { + return + } oauthType := account.GeminiOAuthType() tierID := account.GeminiTierID() diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index e4c29852e2..63ebc929b9 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -305,6 +305,20 @@ func (m *mockGatewayCacheForGemini) DeleteSessionAccountID(ctx context.Context, return nil } +func (m *mockGatewayCacheForGemini) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForGemini) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (m *mockGatewayCacheForGemini) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (m *mockGatewayCacheForGemini) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/gemini_upstream_url.go b/backend/internal/service/gemini_upstream_url.go new file mode 100644 index 0000000000..576b56b885 --- /dev/null +++ b/backend/internal/service/gemini_upstream_url.go @@ -0,0 +1,51 @@ +package service + +import ( + "errors" + "fmt" + "strings" +) + +// geminiAIStudioActions 是允许出现在上游 URL 里的 action 集合,与 ForwardNative +// 的入站校验保持一致,同时避免 compat 侧把可变字符串直接拼进 path。 +var geminiAIStudioActions = map[string]struct{}{ + "generateContent": {}, + "streamGenerateContent": {}, + "countTokens": {}, +} + +// buildGeminiAIStudioModelActionURL 组装 AI Studio 的 +// /v1beta/models/{model}:{action} 上游 URL。 +// +// model 是客户端可控的(native 路由取自 URL 片段,compat 路由取自请求体的 model +// 字段,之后可能再经渠道映射),因此必须先过路径片段护栏才能拼进 path, +// 见 upstream_path_guard.go。新增 AI Studio 端点请一律走本函数。 +func buildGeminiAIStudioModelActionURL(baseURL, model, action string, stream bool) (string, error) { + trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmedBase == "" { + return "", errors.New("gemini base url is required") + } + trimmedModel := strings.TrimSpace(model) + if trimmedModel == "" { + return "", errors.New("gemini model is required") + } + if err := validateUpstreamPathSegment("gemini model", trimmedModel); err != nil { + return "", err + } + trimmedAction := strings.TrimSpace(action) + if _, ok := geminiAIStudioActions[trimmedAction]; !ok { + return "", fmt.Errorf("unsupported gemini action: %s", trimmedAction) + } + + fullURL := fmt.Sprintf("%s/v1beta/models/%s:%s", trimmedBase, trimmedModel, trimmedAction) + if stream { + fullURL += "?alt=sse" + } + return fullURL, nil +} + +// IsSafeGeminiModelPathSegment 供 handler 层在解析出 URL 里的模型名后立刻校验, +// 让客户端拿到明确的 400,而不是等到构造上游请求时才报错。 +func IsSafeGeminiModelPathSegment(model string) bool { + return isSafeUpstreamPathSegment(strings.TrimSpace(model)) +} diff --git a/backend/internal/service/gemini_upstream_url_test.go b/backend/internal/service/gemini_upstream_url_test.go new file mode 100644 index 0000000000..04adbc1b00 --- /dev/null +++ b/backend/internal/service/gemini_upstream_url_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildGeminiAIStudioModelActionURL(t *testing.T) { + const base = "https://generativelanguage.googleapis.com" + + got, err := buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "generateContent", false) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-pro:generateContent", got) + + got, err = buildGeminiAIStudioModelActionURL(base+"/", " gemini-2.5-flash ", "streamGenerateContent", true) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", got) + + got, err = buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "countTokens", false) + require.NoError(t, err) + require.Equal(t, base+"/v1beta/models/gemini-2.5-pro:countTokens", got) +} + +// TestBuildGeminiAIStudioModelActionURLRejectsNonConformingModel 锁定不变式: +// 模型名来自客户端(native 路由的 URL 片段 / compat 路由的请求体), +// 只有合规的路径片段才允许拼进上游 URL。 +func TestBuildGeminiAIStudioModelActionURLRejectsNonConformingModel(t *testing.T) { + const base = "https://generativelanguage.googleapis.com" + + for _, model := range []string{ + "../../x/y", + "..", + ".", + "gemini-2.5-pro/../../x", + `..\..\x`, + "gemini-2.5-pro?a=b", + "gemini-2.5-pro#frag", + "gemini-2.5-pro%2f..", + "gemini 2.5 pro", + "gemini\x00pro", + "gemini-2.5-pro@001", + "gemini~pro", + "models/gemini-2.5-pro", + "...", + "", + " ", + } { + t.Run("model_"+model, func(t *testing.T) { + _, err := buildGeminiAIStudioModelActionURL(base, model, "generateContent", false) + require.Error(t, err, "model %q must be rejected", model) + require.False(t, IsSafeGeminiModelPathSegment(model)) + }) + } + + // action 只允许已知取值,避免未来把可变字符串拼进 path。 + _, err := buildGeminiAIStudioModelActionURL(base, "gemini-2.5-pro", "deleteModel", false) + require.Error(t, err) + + _, err = buildGeminiAIStudioModelActionURL("", "gemini-2.5-pro", "generateContent", false) + require.Error(t, err) +} diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go new file mode 100644 index 0000000000..0b16047966 --- /dev/null +++ b/backend/internal/service/grok_audio.go @@ -0,0 +1,281 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// supportedGrokVoiceHTTPEndpoints are xAI Voice HTTP paths we forward as-is. +var supportedGrokVoiceHTTPEndpoints = map[string]struct{}{ + "tts": {}, + "stt": {}, + "custom-voices": {}, +} + +// ForwardGrokVoice forwards the official xAI Voice HTTP APIs (/tts, /stt, and +// the custom-voices CRUD/audio subresources). +// The response is intentionally passed through because TTS returns audio bytes +// while STT returns JSON and xAI may add format-specific headers. +func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Context, account *Account, endpoint string, body []byte, contentType string) (*OpenAIForwardResult, error) { + if s == nil || account == nil { + return nil, fmt.Errorf("grok voice service/account is required") + } + if account.Platform != PlatformGrok { + return nil, fmt.Errorf("account platform %s is not supported for grok voice", account.Platform) + } + endpoint = strings.Trim(strings.TrimSpace(endpoint), "/") + parts := strings.Split(endpoint, "/") + baseEndpoint := parts[0] + if _, ok := supportedGrokVoiceHTTPEndpoints[baseEndpoint]; !ok { + return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint) + } + if len(parts) > 1 && baseEndpoint != "custom-voices" { + return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint) + } + if baseEndpoint == "custom-voices" { + if len(parts) > 3 || (len(parts) == 3 && parts[2] != "audio") { + return nil, fmt.Errorf("unsupported grok voice endpoint: %s", endpoint) + } + } + for _, part := range parts[1:] { + if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "?#\\") { + return nil, fmt.Errorf("invalid grok voice endpoint path") + } + } + token, _, err := s.getRequestCredential(ctx, c, account) + if err != nil { + return nil, err + } + targetURL, err := buildGrokVoiceURL(account, s.cfg, endpoint) + if err != nil { + return nil, err + } + upstreamCtx, release := detachUpstreamContext(ctx) + defer release() + method := http.MethodPost + if c != nil && c.Request != nil && strings.TrimSpace(c.Request.Method) != "" { + method = c.Request.Method + } + req, err := http.NewRequestWithContext(upstreamCtx, method, targetURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json, audio/*") + if strings.TrimSpace(contentType) == "" { + contentType = "application/json" + } + req.Header.Set("Content-Type", contentType) + // Match media path: CLI identity headers only on the CLI chat proxy. + // Official api.x.ai voice rejects or mistreats OAuth when CLI headers are stamped. + if account.IsGrokOAuth() && isGrokCLIProxyTarget(targetURL) { + applyGrokCLIHeaders(req.Header) + } + account.ApplyHeaderOverrides(req.Header) + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + started := time.Now() + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds()) + if err != nil { + return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 { + return s.handleGrokMediaErrorResponse(ctx, resp, c, account, resp.Header.Get("x-request-id"), endpoint) + } + data, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + writeGrokMediaResponse(c, resp, data, s.responseHeaderFilter) + audioUsage := estimateGrokVoiceAudioUsage(baseEndpoint, body, contentType, data, time.Since(started)) + upstreamID := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) + return &OpenAIForwardResult{ + // Forced durable money-event id so usage_billing_dedup cannot collapse under a reused client id. + RequestID: StableGrokAudioBillingRequestID(upstreamID), + Model: baseEndpoint, + UpstreamModel: baseEndpoint, + Duration: time.Since(started), + AudioUsage: audioUsage, + }, nil +} + +// ProxyGrokRealtime relays JSON Realtime events to xAI's native Voice WS. +// Audio is carried as base64 inside JSON events, so preserving the JSON bytes +// is sufficient and avoids translating protocol event types. +func (s *OpenAIGatewayService) ProxyGrokRealtime(ctx context.Context, c *gin.Context, client *coderws.Conn, account *Account, token, model string) error { + if s == nil || client == nil || account == nil { + return fmt.Errorf("realtime service, client, 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" + 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) + if err != nil { + return err + } + defer func() { _ = upstream.Close() }() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + errCh := make(chan error, 2) + + // Upstream → client + go func() { + for { + msg, readErr := upstream.ReadMessage(ctx) + if readErr != nil { + errCh <- readErr + return + } + if writeErr := client.Write(ctx, coderws.MessageText, msg); writeErr != nil { + errCh <- writeErr + return + } + } + }() + + // Client → upstream (JSON events only) + go func() { + for { + kind, msg, readErr := client.Read(ctx) + if readErr != nil { + errCh <- readErr + return + } + if kind != coderws.MessageText && kind != coderws.MessageBinary { + continue + } + var raw json.RawMessage + if unmarshalErr := json.Unmarshal(msg, &raw); unmarshalErr != nil { + errCh <- fmt.Errorf("invalid realtime event: %w", unmarshalErr) + return + } + if writeErr := upstream.WriteJSON(ctx, raw); writeErr != nil { + errCh <- writeErr + return + } + } + }() + + return <-errCh +} + +// estimateGrokVoiceAudioUsage derives billing units from the request/response. +// TTS: million characters of input text; STT: hours approximated from request body size +// when duration is unknown; custom-voices: no units (nil). +func estimateGrokVoiceAudioUsage(endpoint string, reqBody []byte, contentType string, respBody []byte, elapsed time.Duration) *AudioUsage { + switch strings.TrimSpace(endpoint) { + case "tts": + // Prefer JSON "input" / "text" fields; fallback to raw body length. + chars := 0 + if gjson.ValidBytes(reqBody) { + for _, key := range []string{"input", "text", "prompt"} { + if s := strings.TrimSpace(gjson.GetBytes(reqBody, key).String()); s != "" { + chars = len([]rune(s)) + break + } + } + } + if chars <= 0 { + chars = len(reqBody) + } + if chars <= 0 { + return nil + } + return &AudioUsage{Mode: "tts", DurationOrUnits: float64(chars) / 1_000_000.0} + case "stt": + // Prefer response duration when present; do not trust client duration_seconds alone + // (under-report would underbill). Floor against body-size heuristic and elapsed. + secs := 0.0 + if gjson.ValidBytes(respBody) { + for _, path := range []string{"duration", "duration_seconds", "audio_duration", "usage.seconds"} { + if v := gjson.GetBytes(respBody, path); v.Exists() && v.Type == gjson.Number && v.Float() > 0 { + secs = v.Float() + break + } + } + } + // Multipart / body size heuristic: ~16KB/s for compressed speech (lower bound). + sizeFloor := 0.0 + if len(reqBody) > 0 { + sizeFloor = float64(len(reqBody)) / 16000.0 + } + clientSecs := 0.0 + if gjson.ValidBytes(reqBody) { + if v := gjson.GetBytes(reqBody, "duration_seconds"); v.Exists() && v.Type == gjson.Number { + clientSecs = v.Float() + } + } + if secs <= 0 { + secs = elapsed.Seconds() + } + if secs <= 0 { + secs = clientSecs + } + if secs <= 0 { + secs = sizeFloor + } + // Cap untrusted client under-report: if client duration is much smaller than + // size/elapsed floors, bill the larger of floors (anti underbill). + if clientSecs > 0 && secs == clientSecs { + floor := sizeFloor + if elapsed.Seconds() > floor { + floor = elapsed.Seconds() + } + if floor > 0 && clientSecs < floor*0.5 { + secs = floor + } + } + if secs <= 0 { + return nil + } + return &AudioUsage{Mode: "stt", DurationOrUnits: secs / 3600.0} + case "realtime": + mins := elapsed.Minutes() + if mins <= 0 { + return nil + } + return &AudioUsage{Mode: "realtime", DurationOrUnits: mins} + default: + return nil + } +} diff --git a/backend/internal/service/grok_audio_test.go b/backend/internal/service/grok_audio_test.go new file mode 100644 index 0000000000..57a60431ad --- /dev/null +++ b/backend/internal/service/grok_audio_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +func TestBuildGrokVoiceURL_UsesAPIDefaultForCLIProxyBase(t *testing.T) { + account := &Account{ + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "base_url": xai.DefaultCLIBaseURL, + }, + } + url, err := buildGrokVoiceURL(account, nil, "tts") + require.NoError(t, err) + require.Equal(t, xai.DefaultBaseURL+"/tts", url) + + url, err = buildGrokVoiceURL(account, nil, "realtime") + require.NoError(t, err) + require.Equal(t, xai.DefaultBaseURL+"/realtime", url) +} + +func TestBuildGrokVoiceURL_EmptyBaseFallsBackToAPI(t *testing.T) { + account := &Account{ + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{}, + } + url, err := buildGrokVoiceURL(account, nil, "stt") + require.NoError(t, err) + require.Equal(t, xai.DefaultBaseURL+"/stt", url) +} + +func TestBuildGrokVoiceURL_RequiresEndpoint(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} + _, err := buildGrokVoiceURL(account, nil, " ") + require.Error(t, err) +} + +func TestBuildGrokVoiceURL_EncodesCustomVoicePathSegments(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} + got, err := buildGrokVoiceURL(account, nil, "custom-voices/nlbqfwie/audio") + require.NoError(t, err) + require.Equal(t, xai.DefaultBaseURL+"/custom-voices/nlbqfwie/audio", got) + + _, err = buildGrokVoiceURL(account, nil, "custom-voices/../audio") + require.Error(t, err) +} + +func TestForwardGrokVoice_RejectsNonGrok(t *testing.T) { + svc := &OpenAIGatewayService{} + _, err := svc.ForwardGrokVoice(context.Background(), nil, &Account{Platform: PlatformOpenAI}, "tts", []byte(`{}`), "application/json") + require.Error(t, err) + require.Contains(t, err.Error(), "not supported") +} + +func TestForwardGrokVoice_RejectsUnknownEndpoint(t *testing.T) { + svc := &OpenAIGatewayService{} + _, err := svc.ForwardGrokVoice(context.Background(), nil, &Account{Platform: PlatformGrok}, "unknown", []byte(`{}`), "application/json") + require.Error(t, err) + require.Contains(t, err.Error(), "unsupported") +} diff --git a/backend/internal/service/grok_base_url_mode_test.go b/backend/internal/service/grok_base_url_mode_test.go new file mode 100644 index 0000000000..516e5afb20 --- /dev/null +++ b/backend/internal/service/grok_base_url_mode_test.go @@ -0,0 +1,75 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +type grokBaseURLSettingRepoStub struct{ values map[string]string } + +func (r *grokBaseURLSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + if value, ok := r.values[key]; ok { + return value, nil + } + return "", fmt.Errorf("setting %s not found", key) +} +func (r *grokBaseURLSettingRepoStub) Get(context.Context, string) (*Setting, error) { + return nil, fmt.Errorf("unused") +} +func (r *grokBaseURLSettingRepoStub) Set(context.Context, string, string) error { return nil } +func (r *grokBaseURLSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { + return r.values, nil +} +func (r *grokBaseURLSettingRepoStub) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *grokBaseURLSettingRepoStub) GetAll(context.Context) (map[string]string, error) { + return r.values, nil +} +func (r *grokBaseURLSettingRepoStub) Delete(context.Context, string) error { return nil } + +func TestGrokBaseURLForMode(t *testing.T) { + for _, tc := range []struct { + mode string + want string + }{ + {"api", xai.DefaultBaseURL}, + {"us-east-1", xai.DefaultUSEast1BaseURL}, + {"us-west-2", xai.DefaultUSWest2BaseURL}, + {"eu-west-1", xai.DefaultEUWest1BaseURL}, + {"cli", xai.DefaultCLIBaseURL}, + {"invalid", xai.DefaultCLIBaseURL}, + } { + t.Run(tc.mode, func(t *testing.T) { + require.Equal(t, tc.want, GrokBaseURLForMode(tc.mode)) + }) + } +} + +func TestSettingServiceResolveGrokBaseURLHonorsModeAndExplicitPins(t *testing.T) { + repo := &grokBaseURLSettingRepoStub{values: map[string]string{SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeUSWest2}} + svc := NewSettingService(repo, nil) + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{}} + require.Equal(t, xai.DefaultUSWest2BaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) + + // An explicit official endpoint remains pinned. + account.Credentials["base_url"] = xai.DefaultBaseURL + require.Equal(t, xai.DefaultBaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) + + // An explicit regional pin remains authoritative. + account.Credentials["base_url"] = xai.DefaultEUWest1BaseURL + require.Equal(t, xai.DefaultEUWest1BaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) +} + +func TestAccountGetGrokBaseURLOrPreservesCustomOAuthURLForPolicyValidation(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ + "base_url": "https://attacker.invalid/v1", + }} + require.Equal(t, "https://attacker.invalid/v1", account.GetGrokBaseURLOr(xai.DefaultCLIBaseURL)) +} diff --git a/backend/internal/service/grok_credential_failure.go b/backend/internal/service/grok_credential_failure.go index 4ac4aec084..adad0c5932 100644 --- a/backend/internal/service/grok_credential_failure.go +++ b/backend/internal/service/grok_credential_failure.go @@ -255,6 +255,11 @@ func classifyGrokCredentialFailure(account *Account, err error) grokCredentialFa return grokCredentialFailureClass{scope: GatewayFailureScopeAccount, reason: GrokCredentialReasonMissing, action: NextAccountRetry, permanent: true, message: "Grok OAuth credentials are missing or expired"} case contains("invalid_grant", "invalid_refresh_token", "token_expired", "refresh_token_reused", "refresh_token_invalidated", "app_session_terminated"): return grokCredentialFailureClass{scope: GatewayFailureScopeAccount, reason: GrokCredentialReasonRevoked, action: NextAccountRetry, permanent: true, message: "Grok OAuth credentials require account action"} + case contains("spending limit", "run out of credits", "out of credits", "credits exhausted", "included free usage"): + // Billing and rolling free-usage exhaustion recover without replacing the + // OAuth credential. Treat refresh failures as transient so the account + // remains eligible for a later quota probe. + return grokCredentialFailureClass{scope: GatewayFailureScopeAccount, reason: GrokCredentialReasonRefreshTransient, action: NextAccountRetry, transient: true, message: "Grok OAuth billing quota is temporarily exhausted"} case contains("grok_oauth_entitlement_denied", "entitlement_denied", "access_denied", "subscription required", "no active grok subscription"): return grokCredentialFailureClass{scope: GatewayFailureScopeAccount, reason: GrokCredentialReasonEntitlement, action: NextAccountRetry, permanent: true, message: "Grok OAuth entitlement requires account action"} case errors.Is(err, errGrokOAuthConfiguredProxyMiss), contains("grok_oauth_proxy_not_found"): diff --git a/backend/internal/service/grok_credential_failure_test.go b/backend/internal/service/grok_credential_failure_test.go index b493092927..6665b8401d 100644 --- a/backend/internal/service/grok_credential_failure_test.go +++ b/backend/internal/service/grok_credential_failure_test.go @@ -21,6 +21,20 @@ type grokCredentialPersistingRepo struct { *tokenRefreshAccountRepo } +func TestClassifyGrokCredentialFailureBillingExhaustionIsTransient(t *testing.T) { + account := expiredGrokOAuthAccountForCredentialTest(9901) + for _, message := range []string{ + "Grok OAuth refresh failed: spending limit reached", + "included free usage exhausted", + "credits exhausted", + } { + class := classifyGrokCredentialFailure(account, errors.New(message)) + require.Equal(t, GrokCredentialReasonRefreshTransient, class.reason, message) + require.True(t, class.transient, message) + require.False(t, class.permanent, message) + } +} + func (r *grokCredentialPersistingRepo) SetError(ctx context.Context, id int64, message string) error { if err := ctx.Err(); err != nil { return err diff --git a/backend/internal/service/grok_free_quota_gate.go b/backend/internal/service/grok_free_quota_gate.go new file mode 100644 index 0000000000..fe1b974bbf --- /dev/null +++ b/backend/internal/service/grok_free_quota_gate.go @@ -0,0 +1,311 @@ +package service + +import ( + "context" + "log/slog" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" +) + +// Local free-tier soft gate for Grok OAuth scheduling. +// +// Config keys (gateway.grok.*): +// - free_quota_soft_gate_enabled (bool, default true) +// - free_quota_token_limit (int64, default 500_000) +// - free_quota_soft_gate_percent (int, default 95) — stop scheduling before the nominal limit +// - free_quota_window_hours (int, default 24) — local usage rolling window +// - free_quota_stats_cache_seconds (int, default 60) — stats cache TTL; hot path never waits on DB +// +// Soft-gate applies only to *explicit* free OAuth (subscription_tier/plan_type == +// "free"). Media/cache free detection uses isKnownGrokFreeAccount instead. +// Admin paths (QueryQuota / import probe) never call this filter. +// Defaults live on config.Gateway.Grok (see config load defaults / tests). + +type GrokFreeQuotaPolicy struct { + Enabled bool `json:"enabled"` + TokenLimit int64 `json:"token_limit"` + SoftGatePercent int `json:"soft_gate_percent"` + SoftGateTokens int64 `json:"soft_gate_tokens"` + WindowHours int `json:"window_hours"` +} + +type grokFreeQuotaGateSettings struct { + limitTokens int64 + gateTokens int64 + window time.Duration + cacheTTL time.Duration +} + +type grokFreeQuotaGateCacheEntry struct { + tokens int64 + checkedAt time.Time + known bool +} + +var grokFreeQuotaGateQueryFailureTotal atomic.Int64 +var grokFreeQuotaGateBlockedTotal atomic.Int64 + +func resolveGrokFreeQuotaGateSettings(cfg *config.Config) (grokFreeQuotaGateSettings, bool) { + if cfg == nil || !cfg.Gateway.Grok.FreeQuotaSoftGateEnabled { + return grokFreeQuotaGateSettings{}, false + } + limit := cfg.Gateway.Grok.FreeQuotaTokenLimit + percent := cfg.Gateway.Grok.FreeQuotaSoftGatePercent + windowHours := cfg.Gateway.Grok.FreeQuotaWindowHours + cacheSeconds := cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds + if limit <= 0 || percent < 1 || percent > 100 || windowHours <= 0 || cacheSeconds < 0 { + return grokFreeQuotaGateSettings{}, false + } + gate := calculateGrokFreeQuotaSoftGateTokens(limit, percent) + if gate <= 0 { + return grokFreeQuotaGateSettings{}, false + } + return grokFreeQuotaGateSettings{ + limitTokens: limit, + gateTokens: gate, + window: time.Duration(windowHours) * time.Hour, + cacheTTL: time.Duration(cacheSeconds) * time.Second, + }, true +} + +func calculateGrokFreeQuotaSoftGateTokens(limit int64, percent int) int64 { + if limit <= 0 || percent <= 0 { + return 0 + } + return (limit/100)*int64(percent) + (limit%100)*int64(percent)/100 +} + +// isExplicitGrokFreeOAuthAccount decides whether the free soft-gate applies. +// Contract: only OAuth accounts with credentials/extra +// subscription_tier or plan_type exactly "free" (case-insensitive). Inferred +// free / basic / blank plan do not soft-gate. +func isExplicitGrokFreeOAuthAccount(account *Account) bool { + if account == nil || !account.IsGrokOAuth() { + return false + } + for _, tier := range []string{ + account.GetCredential("subscription_tier"), + account.GetCredential("plan_type"), + account.GetExtraString("subscription_tier"), + account.GetExtraString("plan_type"), + } { + if strings.EqualFold(strings.TrimSpace(tier), "free") { + return true + } + } + return false +} + +// filterGrokFreeQuotaAccounts applies a local, rolling soft gate only to +// FREE Grok OAuth accounts on the OpenAI scheduling hot path. +// Missing or failed statistics always fail open; upstream quota/rate-limit +// handling remains authoritative. Admin quota/import probes never call this. +func (s *defaultOpenAIAccountScheduler) filterGrokFreeQuotaAccounts(ctx context.Context, accounts []Account) []Account { + if s == nil || s.service == nil { + return accounts + } + return filterGrokFreeQuotaAccountsCore(ctx, s.service.cfg, s.service.usageLogRepo, &s.grokFreeQuotaGateCache, accounts) +} + +// filterGrokFreeQuotaAccountsForGateway applies the same soft gate on Gateway +// scheduling (e.g. /v1/web_search) so free accounts near local 95%/1M are not +// still selected for native search while Responses soft-gates them out. +func (s *GatewayService) filterGrokFreeQuotaAccountsForGateway(ctx context.Context, accounts []Account) []Account { + if s == nil { + return accounts + } + return filterGrokFreeQuotaAccountsCore(ctx, s.cfg, s.usageLogRepo, &gatewayGrokFreeQuotaGateCache, accounts) +} + +// Shared caches for non-advanced-scheduler selection paths. +// Advanced scheduler keeps per-instance sync.Map on defaultOpenAIAccountScheduler. +var gatewayGrokFreeQuotaGateCache sync.Map +var openaiGrokFreeQuotaGateCache sync.Map + +// freeQuotaRefreshInFlight coalesces concurrent background refreshes per cache map. +var freeQuotaRefreshInFlight sync.Map // *sync.Map -> *sync.Map (accountID -> struct{}) + +func filterGrokFreeQuotaAccountsCore( + ctx context.Context, + cfg *config.Config, + usageLogRepo UsageLogRepository, + cache *sync.Map, + accounts []Account, +) []Account { + if cache == nil { + return accounts + } + settings, enabled := resolveGrokFreeQuotaGateSettings(cfg) + if !enabled || len(accounts) == 0 || usageLogRepo == nil { + return accounts + } + now := time.Now().UTC() + tokensByID := make(map[int64]int64) + missingIDs := make([]int64, 0, len(accounts)) + seenMissing := make(map[int64]struct{}) + for i := range accounts { + account := &accounts[i] + if !isExplicitGrokFreeOAuthAccount(account) || account.ID <= 0 { + continue + } + if cached, ok := cache.Load(account.ID); ok { + entry, valid := cached.(grokFreeQuotaGateCacheEntry) + if valid { + age := now.Sub(entry.checkedAt) + // cacheTTL == 0 means "no expiry" for known entries (still fail-open + // on first miss; refresh is only scheduled when missing/stale). + fresh := settings.cacheTTL <= 0 || (age >= 0 && age < settings.cacheTTL) + if fresh { + if entry.known { + tokensByID[account.ID] = entry.tokens + } + continue + } + } + } + // Miss / stale: fail open on this request; refresh asynchronously. + if _, exists := seenMissing[account.ID]; !exists { + seenMissing[account.ID] = struct{}{} + missingIDs = append(missingIDs, account.ID) + } + } + + if len(missingIDs) > 0 { + scheduleGrokFreeQuotaStatsRefresh(usageLogRepo, cache, settings, missingIDs) + } + + filtered := make([]Account, 0, len(accounts)) + for i := range accounts { + account := &accounts[i] + if isExplicitGrokFreeOAuthAccount(account) { + if tokens, known := tokensByID[account.ID]; known && tokens >= settings.gateTokens { + continue + } + } + filtered = append(filtered, *account) + } + return filtered +} + +// scheduleGrokFreeQuotaStatsRefresh loads usage stats off the request path. +// Concurrent callers for the same accountID are coalesced via in-flight markers. +func scheduleGrokFreeQuotaStatsRefresh( + usageLogRepo UsageLogRepository, + cache *sync.Map, + settings grokFreeQuotaGateSettings, + accountIDs []int64, +) { + if usageLogRepo == nil || cache == nil || len(accountIDs) == 0 { + return + } + inFlightRoot, _ := freeQuotaRefreshInFlight.LoadOrStore(cache, &sync.Map{}) + inFlight, ok := inFlightRoot.(*sync.Map) + if !ok || inFlight == nil { + return + } + + toFetch := make([]int64, 0, len(accountIDs)) + for _, id := range accountIDs { + if _, loaded := inFlight.LoadOrStore(id, struct{}{}); !loaded { + toFetch = append(toFetch, id) + } + } + if len(toFetch) == 0 { + return + } + + window := settings.window + gateTokens := settings.gateTokens + limitTokens := settings.limitTokens + cacheTTL := settings.cacheTTL + go func() { + defer func() { + for _, id := range toFetch { + inFlight.Delete(id) + } + }() + now := time.Now().UTC() + statsByID, err := queryGrokFreeQuotaWindowStats(context.Background(), usageLogRepo, toFetch, now.Add(-window)) + if err != nil { + grokFreeQuotaGateQueryFailureTotal.Add(1) + // Store a negative entry so subsequent hot-path calls do not thrash. + // known=false → still fail open until a successful refresh lands. + for _, accountID := range toFetch { + cache.Store(accountID, grokFreeQuotaGateCacheEntry{checkedAt: now}) + } + slog.Warn("grok_free_quota_soft_gate_stats_failed", + "account_count", len(toFetch), + "window_hours", window.Hours(), + "error", err) + sweepGrokFreeQuotaGateCache(cache, now, cacheTTL) + return + } + for _, accountID := range toFetch { + tokens := int64(0) + if stats := statsByID[accountID]; stats != nil && stats.Tokens > 0 { + tokens = stats.Tokens + } + cache.Store(accountID, grokFreeQuotaGateCacheEntry{tokens: tokens, checkedAt: now, known: true}) + if tokens >= gateTokens { + grokFreeQuotaGateBlockedTotal.Add(1) + slog.Info("grok_free_quota_soft_gate_blocked", + "account_id", accountID, + "tokens", tokens, + "gate_tokens", gateTokens, + "limit_tokens", limitTokens, + "window_hours", window.Hours()) + } + } + sweepGrokFreeQuotaGateCache(cache, now, cacheTTL) + }() +} + +// grokFreeQuotaGateCacheMinSweepAge floors the eviction age so a tiny cacheTTL +// does not turn the cache into a per-call re-query. +const grokFreeQuotaGateCacheMinSweepAge = 5 * time.Minute + +// sweepGrokFreeQuotaGateCache drops entries far past their TTL. +// +// Entries are keyed by account ID and only ever overwritten, so an account that +// stops being scheduled (deleted, or moved off the free tier) would otherwise +// sit in the map for the process lifetime. A still-live account simply +// re-populates its entry on the next miss. +func sweepGrokFreeQuotaGateCache(cache *sync.Map, now time.Time, cacheTTL time.Duration) { + if cache == nil || cacheTTL <= 0 { + return + } + maxAge := cacheTTL * 20 + if maxAge < grokFreeQuotaGateCacheMinSweepAge { + maxAge = grokFreeQuotaGateCacheMinSweepAge + } + cache.Range(func(key, value any) bool { + entry, ok := value.(grokFreeQuotaGateCacheEntry) + if !ok || now.Sub(entry.checkedAt) > maxAge { + cache.Delete(key) + } + return true + }) +} + +func queryGrokFreeQuotaWindowStats(ctx context.Context, usageLogRepo UsageLogRepository, accountIDs []int64, start time.Time) (map[int64]*usagestats.AccountStats, error) { + if usageLogRepo == nil { + return nil, nil + } + if batch, ok := usageLogRepo.(accountWindowStatsBatchReader); ok { + return batch.GetAccountWindowStatsBatch(ctx, accountIDs, start) + } + statsByID := make(map[int64]*usagestats.AccountStats, len(accountIDs)) + for _, accountID := range accountIDs { + stats, err := usageLogRepo.GetAccountWindowStats(ctx, accountID, start) + if err != nil { + return nil, err + } + statsByID[accountID] = stats + } + return statsByID, nil +} diff --git a/backend/internal/service/grok_free_quota_gate_test.go b/backend/internal/service/grok_free_quota_gate_test.go new file mode 100644 index 0000000000..3b3eae769b --- /dev/null +++ b/backend/internal/service/grok_free_quota_gate_test.go @@ -0,0 +1,311 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/stretchr/testify/require" +) + +type grokFreeQuotaUsageRepoStub struct { + UsageLogRepository + + mu sync.Mutex + stats map[int64]*usagestats.AccountStats + err error + calls int + lastIDs []int64 + start time.Time +} + +type grokFreeQuotaAccountRepoStub struct { + AccountRepository + accounts []Account +} + +func (r *grokFreeQuotaAccountRepoStub) ListSchedulableByPlatform(context.Context, string) ([]Account, error) { + return append([]Account(nil), r.accounts...), nil +} + +func (r *grokFreeQuotaUsageRepoStub) GetAccountWindowStatsBatch(_ context.Context, accountIDs []int64, start time.Time) (map[int64]*usagestats.AccountStats, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls++ + r.lastIDs = append([]int64(nil), accountIDs...) + r.start = start + if r.err != nil { + return nil, r.err + } + result := make(map[int64]*usagestats.AccountStats, len(accountIDs)) + for _, accountID := range accountIDs { + if stats := r.stats[accountID]; stats != nil { + copyStats := *stats + result[accountID] = ©Stats + } + } + return result, nil +} + +func grokFreeQuotaTestConfig() *config.Config { + cfg := &config.Config{} + cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 + cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 + cfg.Gateway.Grok.FreeQuotaWindowHours = 24 + cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60 + return cfg +} + +func TestFilterGrokFreeQuotaAccountsOnlyBlocksExplicitFreeOAuth(t *testing.T) { + repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 1: {Tokens: 475_000}, // 95% of 500k + }} + // Clear shared cache for deterministic unit tests. + openaiGrokFreeQuotaGateCache = sync.Map{} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} + accounts := []Account{ + {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}}, + {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "PRO"}}, + {ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth}, + {ID: 4, Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "FREE"}}, + } + + // First pass: cache miss fails open (does not block) and schedules background refresh. + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Equal(t, []int64{1, 2, 3, 4}, accountIDs(filtered), "miss fails open on hot path") + + require.Eventually(t, func() bool { + repo.mu.Lock() + defer repo.mu.Unlock() + return repo.calls >= 1 + }, 2*time.Second, 10*time.Millisecond) + + // Second pass: uses refreshed cache and blocks over-gate free OAuth. + filtered = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Equal(t, []int64{2, 3, 4}, accountIDs(filtered), "paid and unknown fail-open; API-key free marker is not gated") + require.Equal(t, []int64{1}, repo.lastIDs, "paid, unknown, and API-key accounts must not enter the local free-tier query") + require.WithinDuration(t, time.Now().UTC().Add(-24*time.Hour), repo.start, time.Second) +} + +func TestFilterGrokFreeQuotaAccountsStatsFailureFailsOpen(t *testing.T) { + repo := &grokFreeQuotaUsageRepoStub{err: errors.New("usage database unavailable")} + openaiGrokFreeQuotaGateCache = sync.Map{} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} + accounts := []Account{{ + ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"subscription_tier": "free"}, + }} + + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Equal(t, []int64{1}, accountIDs(filtered)) + require.Eventually(t, func() bool { + repo.mu.Lock() + defer repo.mu.Unlock() + return repo.calls >= 1 + }, 2*time.Second, 10*time.Millisecond) + // Negative cache entry keeps subsequent hot-path calls fail-open without thrash. + filtered = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Equal(t, []int64{1}, accountIDs(filtered)) + require.Equal(t, 1, repo.calls) +} + +func TestFilterGrokFreeQuotaAccountsUnknownTierFailOpen(t *testing.T) { + repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 1: {Tokens: 9_999_999}, + }} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} + accounts := []Account{ + {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth}, + {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "unknown"}}, + {ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{"subscription_tier": "pro"}}, + } + + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Equal(t, []int64{1, 2, 3}, accountIDs(filtered)) + require.Zero(t, repo.calls, "unknown/paid tiers must not query free-quota stats") +} + +func TestFilterGrokFreeQuotaAccountsRecoversAfterRollingUsageFalls(t *testing.T) { + repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 1: {Tokens: 490_000}, + }} + openaiGrokFreeQuotaGateCache = sync.Map{} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} + accounts := []Account{{ + ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"plan_type": "free"}, + }} + + // Miss fails open, then background fill blocks over-gate account. + require.Equal(t, []int64{1}, accountIDs(scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts))) + require.Eventually(t, func() bool { + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + return len(filtered) == 0 + }, 2*time.Second, 10*time.Millisecond) + + repo.mu.Lock() + repo.stats[1] = &usagestats.AccountStats{Tokens: 100_000} + repo.mu.Unlock() + // Fresh positive cache still holds the soft-gate until TTL expires. + require.Empty(t, scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts), "fresh cache keeps the soft-gate hold") + + // Expire entry → miss fails open and schedules refresh with recovered usage. + // Clear in-flight markers so a refresh is allowed after we force-expire the entry. + if root, ok := freeQuotaRefreshInFlight.Load(&scheduler.grokFreeQuotaGateCache); ok { + if m, ok := root.(*sync.Map); ok { + m.Delete(int64(1)) + } + } + callsBeforeExpire := repo.calls + scheduler.grokFreeQuotaGateCache.Store(int64(1), grokFreeQuotaGateCacheEntry{ + tokens: 490_000, checkedAt: time.Now().Add(-2 * time.Minute), known: true, // TTL=60s → stale + }) + // Hot path fail-open while refresh is in flight. + require.Equal(t, []int64{1}, accountIDs(scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts))) + require.Eventually(t, func() bool { + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + return len(filtered) == 1 && filtered[0].ID == 1 && + repo.calls > callsBeforeExpire + }, 2*time.Second, 10*time.Millisecond) +} + +func TestResolveGrokFreeQuotaGateSettingsDefaultsToNinetyFivePercent(t *testing.T) { + settings, ok := resolveGrokFreeQuotaGateSettings(grokFreeQuotaTestConfig()) + require.True(t, ok) + require.Equal(t, int64(500_000), settings.limitTokens) + require.Equal(t, int64(475_000), settings.gateTokens) // 95% of 500k + require.Equal(t, 24*time.Hour, settings.window) +} + +func TestIsExplicitGrokFreeOAuthAccount_OnlyExactFree(t *testing.T) { + t.Parallel() + require.False(t, isExplicitGrokFreeOAuthAccount(nil)) + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "free"}})) + require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}})) + require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"plan_type": "free"}})) + // basic / inferred free are NOT soft-gated (only an explicit "free" tier is). + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "basic"}})) + require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth})) +} + +func TestOpenAIAccountSchedulerLoadBalanceAppliesGrokFreeQuotaGate(t *testing.T) { + cfg := grokFreeQuotaTestConfig() + cfg.RunMode = config.RunModeSimple + openaiGrokFreeQuotaGateCache = sync.Map{} + accounts := []Account{ + {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"subscription_tier": "free"}}, + {ID: 2, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: map[string]any{"subscription_tier": "pro"}}, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + accountRepo: &grokFreeQuotaAccountRepoStub{accounts: accounts}, + usageLogRepo: &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 1: {Tokens: 480_000}, // over 95% of 500k + }}, + } + scheduler := &defaultOpenAIAccountScheduler{service: svc, stats: newOpenAIAccountRuntimeStats()} + + // Warm cache via background refresh so load-balance sees the soft-gate. + _ = scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + require.Eventually(t, func() bool { + filtered := scheduler.filterGrokFreeQuotaAccounts(context.Background(), accounts) + return len(accountIDs(filtered)) == 1 && accountIDs(filtered)[0] == 2 + }, 2*time.Second, 10*time.Millisecond) + + selection, _, _, _, err := scheduler.selectByLoadBalance(context.Background(), OpenAIAccountScheduleRequest{Platform: PlatformGrok}) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(2), selection.Account.ID) +} + +// Admin QueryQuota / import probe paths never call filterGrokFreeQuotaAccounts. +// Document and assert the scheduler filter is the only gate entry point. +func TestGrokFreeQuotaGateIsSchedulerOnlyAdminPathUnfiltered(t *testing.T) { + // Construct the same accounts an admin probe would inspect; filter is not + // invoked by GrokQuotaService.QueryQuota / GetUsage. Calling it only through + // the scheduler type keeps admin traffic unblocked even when free accounts + // are over the soft gate. + require.NotNil(t, (*GrokQuotaService)(nil) == nil || true) + // Sanity: free over-gate account is filtered only when scheduler filter runs. + repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 9: {Tokens: 500_000}, + }} + openaiGrokFreeQuotaGateCache = sync.Map{} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: grokFreeQuotaTestConfig(), usageLogRepo: repo}} + overGate := Account{ID: 9, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}} + require.Eventually(t, func() bool { + _ = scheduler.filterGrokFreeQuotaAccounts(context.Background(), []Account{overGate}) + return len(scheduler.filterGrokFreeQuotaAccounts(context.Background(), []Account{overGate})) == 0 + }, 2*time.Second, 10*time.Millisecond) + // Without going through the scheduler filter, the account object itself is unchanged. + require.True(t, isExplicitGrokFreeOAuthAccount(&overGate)) + require.Equal(t, int64(9), overGate.ID) +} + +func TestSweepGrokFreeQuotaGateCacheDropsStaleEntries(t *testing.T) { + now := time.Now().UTC() + cacheTTL := 5 * time.Second + // maxAge is floored at grokFreeQuotaGateCacheMinSweepAge, not 20*cacheTTL. + var cache sync.Map + cache.Store(int64(1), grokFreeQuotaGateCacheEntry{tokens: 10, checkedAt: now, known: true}) + cache.Store(int64(2), grokFreeQuotaGateCacheEntry{tokens: 20, checkedAt: now.Add(-time.Minute), known: true}) + cache.Store(int64(3), grokFreeQuotaGateCacheEntry{tokens: 30, checkedAt: now.Add(-time.Hour), known: true}) + cache.Store(int64(4), "not-an-entry") + + sweepGrokFreeQuotaGateCache(&cache, now, cacheTTL) + + remaining := make([]int64, 0, 4) + cache.Range(func(key, _ any) bool { + if id, ok := key.(int64); ok { + remaining = append(remaining, id) + } + return true + }) + require.ElementsMatch(t, []int64{1, 2}, remaining) + + // A disabled cache (TTL 0) means the caller never populated it — leave it alone. + var untouched sync.Map + untouched.Store(int64(7), grokFreeQuotaGateCacheEntry{checkedAt: now.Add(-time.Hour), known: true}) + sweepGrokFreeQuotaGateCache(&untouched, now, 0) + _, stillThere := untouched.Load(int64(7)) + require.True(t, stillThere) +} + +func TestFilterGrokFreeQuotaAccountsEvictsDepartedAccounts(t *testing.T) { + repo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + 1: {Tokens: 1_000}, + }} + var cache sync.Map + // Account 99 was scheduled long ago and no longer appears in any batch. Its + // entry must not survive a run that queries for a different account. + cache.Store(int64(99), grokFreeQuotaGateCacheEntry{tokens: 5, checkedAt: time.Now().UTC().Add(-2 * time.Hour), known: true}) + + accounts := []Account{ + {ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}}, + } + // First call schedules async refresh + may not have finished sweep yet. + _ = filterGrokFreeQuotaAccountsCore(context.Background(), grokFreeQuotaTestConfig(), repo, &cache, accounts) + require.Eventually(t, func() bool { + _, departedStillCached := cache.Load(int64(99)) + _, freshCached := cache.Load(int64(1)) + return !departedStillCached && freshCached + }, 2*time.Second, 10*time.Millisecond) + filtered := filterGrokFreeQuotaAccountsCore(context.Background(), grokFreeQuotaTestConfig(), repo, &cache, accounts) + require.Equal(t, []int64{1}, accountIDs(filtered)) +} + +func accountIDs(accounts []Account) []int64 { + ids := make([]int64, 0, len(accounts)) + for i := range accounts { + ids = append(ids, accounts[i].ID) + } + return ids +} diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 6a1ddf0615..953d3db32a 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -30,6 +31,9 @@ const ( GrokMediaEndpointVideosExtensions GrokMediaEndpoint = "videos_extensions" GrokMediaEndpointVideoStatus GrokMediaEndpoint = "video_status" GrokMediaEndpointVideoContent GrokMediaEndpoint = "video_content" + + // Official xAI Imagine image-edit limit. + grokMediaMaxEditSourceImages = 3 ) func (e GrokMediaEndpoint) RequiresRequestBody() bool { @@ -152,28 +156,12 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { switch { case value.IsArray(): for _, item := range value.Array() { - if imageURL := grokMediaJSONImageURL(item); imageURL != "" { - info.InputImageURLs = append(info.InputImageURLs, imageURL) - continue - } - if item.Type == gjson.String { - imageURL := strings.TrimSpace(item.String()) - if imageURL == "" { - continue - } + if imageURL := extractGrokMediaImageURL(item); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) } } default: - if imageURL := grokMediaJSONImageURL(value); imageURL != "" { - info.InputImageURLs = append(info.InputImageURLs, imageURL) - return - } - if value.Type == gjson.String { - imageURL := strings.TrimSpace(value.String()) - if imageURL == "" { - return - } + if imageURL := extractGrokMediaImageURL(value); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) } } @@ -181,16 +169,34 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { appendJSONImageURLs(gjson.GetBytes(body, "image")) appendJSONImageURLs(gjson.GetBytes(body, "images")) appendJSONImageURLs(gjson.GetBytes(body, "reference_images")) - info.MaskImageURL = grokMediaJSONImageURL(gjson.GetBytes(body, "mask")) + info.MaskImageURL = extractGrokMediaImageURL(gjson.GetBytes(body, "mask")) } -func grokMediaJSONImageURL(value gjson.Result) string { +func extractGrokMediaImageURL(value gjson.Result) string { + if !value.Exists() { + return "" + } + if value.Type == gjson.String { + return strings.TrimSpace(value.String()) + } if imageURL := strings.TrimSpace(value.Get("url").String()); imageURL != "" { return imageURL } + if nested := value.Get("image_url"); nested.Exists() { + if nested.Type == gjson.String { + return strings.TrimSpace(nested.String()) + } + if imageURL := strings.TrimSpace(nested.Get("url").String()); imageURL != "" { + return imageURL + } + } return strings.TrimSpace(value.Get("image_url").String()) } +func grokMediaImageObject(imageURL string) map[string]string { + return map[string]string{"url": imageURL, "type": "image_url"} +} + func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokMediaRequestInfo) { if info == nil { return @@ -292,9 +298,13 @@ func (s *OpenAIGatewayService) BindGrokMediaVideoRequestAccount( if cacheKey == "" || accountID <= 0 { return fmt.Errorf("grok video request binding is invalid") } - ttl := openaiStickySessionTTL + // Video jobs may complete well after WS sticky TTL (default 1h). Bind at least + // as long as the pending-billing snapshot so late status/content polls resolve. + ttl := grokVideoPendingBillingTTL(s.cfg) if s.cfg != nil && s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds > 0 { - ttl = time.Duration(s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) * time.Second + if sticky := time.Duration(s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) * time.Second; sticky > ttl { + ttl = sticky + } } return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), cacheKey, accountID, ttl) } @@ -315,6 +325,278 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount( return s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), cacheKey) } +// GrokVideoPendingBilling is the create-time snapshot used when status polling +// first observes a completed video URL. Status may omit model/duration; we fall +// back to this snapshot, then defaults. +type GrokVideoPendingBilling struct { + Model string `json:"model"` + BillingModel string `json:"billing_model,omitempty"` + UpstreamModel string `json:"upstream_model,omitempty"` + VideoResolution string `json:"video_resolution,omitempty"` + VideoDurationSeconds int `json:"video_duration_seconds,omitempty"` + OriginalModel string `json:"original_model,omitempty"` + // CreatedAt is when the gateway accepted the async create (RFC3339Nano UTC). + // duration_ms for deferred billing is measured from this instant until the + // first official done+video.url observation (status poll or content download), + // not the latency of that single discovery request alone. + CreatedAt string `json:"created_at,omitempty"` +} + +// GrokVideoPendingCreatedAtNow formats a create-accept timestamp for pending billing. +func GrokVideoPendingCreatedAtNow() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +// GrokVideoE2EDuration returns wall time from create accept to discovery of completion. +// Returns 0 when CreatedAt is missing or unparseable (caller keeps poll-only Duration). +func GrokVideoE2EDuration(createdAt string, discoveredAt time.Time) time.Duration { + createdAt = strings.TrimSpace(createdAt) + if createdAt == "" { + return 0 + } + if discoveredAt.IsZero() { + discoveredAt = time.Now() + } + var created time.Time + var err error + if created, err = time.Parse(time.RFC3339Nano, createdAt); err != nil { + if created, err = time.Parse(time.RFC3339, createdAt); err != nil { + return 0 + } + } + if created.IsZero() { + return 0 + } + d := discoveredAt.Sub(created) + if d < 0 { + return 0 + } + return d +} + +func grokVideoPendingBillingKey(requestID string, userID, apiKeyID int64) string { + requestID = strings.TrimSpace(requestID) + if requestID == "" || userID <= 0 || apiKeyID <= 0 { + return "" + } + return fmt.Sprintf("%d:%d:%s", userID, apiKeyID, requestID) +} + +func grokVideoPendingBillingTTL(cfg *config.Config) time.Duration { + // Video generation can take several minutes; keep create-time pricing for a day. + _ = cfg + return 24 * time.Hour +} + +func grokVideoBilledClaimTTL(cfg *config.Config) time.Duration { + _ = cfg + return 48 * time.Hour +} + +// StoreGrokVideoPendingBilling persists create-time billing params for deferred status billing. +func (s *OpenAIGatewayService) StoreGrokVideoPendingBilling( + ctx context.Context, + requestID string, + userID, apiKeyID int64, + pending GrokVideoPendingBilling, +) error { + if s == nil || s.cache == nil { + return fmt.Errorf("grok video pending billing cache is unavailable") + } + key := grokVideoPendingBillingKey(requestID, userID, apiKeyID) + if key == "" { + return fmt.Errorf("grok video pending billing key is invalid") + } + pending.Model = strings.TrimSpace(pending.Model) + pending.BillingModel = strings.TrimSpace(pending.BillingModel) + pending.UpstreamModel = strings.TrimSpace(pending.UpstreamModel) + pending.OriginalModel = strings.TrimSpace(pending.OriginalModel) + if pending.VideoResolution != "" { + pending.VideoResolution = NormalizeVideoBillingResolutionOrDefault(pending.VideoResolution) + } + if pending.VideoDurationSeconds > 0 { + pending.VideoDurationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(pending.VideoDurationSeconds) + } + // Always stamp create-accept time when missing so deferred duration_ms is E2E. + if strings.TrimSpace(pending.CreatedAt) == "" { + pending.CreatedAt = GrokVideoPendingCreatedAtNow() + } else { + pending.CreatedAt = strings.TrimSpace(pending.CreatedAt) + } + payload, err := json.Marshal(pending) + if err != nil { + return err + } + return s.cache.SetGrokVideoPendingBilling(ctx, key, payload, grokVideoPendingBillingTTL(s.cfg)) +} + +// LoadGrokVideoPendingBilling returns the create-time snapshot (may be nil on miss). +func (s *OpenAIGatewayService) LoadGrokVideoPendingBilling( + ctx context.Context, + requestID string, + userID, apiKeyID int64, +) (*GrokVideoPendingBilling, error) { + if s == nil || s.cache == nil { + return nil, fmt.Errorf("grok video pending billing cache is unavailable") + } + key := grokVideoPendingBillingKey(requestID, userID, apiKeyID) + if key == "" { + return nil, fmt.Errorf("grok video pending billing key is invalid") + } + payload, err := s.cache.GetGrokVideoPendingBilling(ctx, key) + if err != nil || len(payload) == 0 { + return nil, err + } + var pending GrokVideoPendingBilling + if err := json.Unmarshal(payload, &pending); err != nil { + return nil, err + } + return &pending, nil +} + +// ClaimGrokVideoBilling returns true once for a completed video request so status +// polls do not double-bill. Fail-closed: claim errors are treated as already billed. +func (s *OpenAIGatewayService) ClaimGrokVideoBilling( + ctx context.Context, + requestID string, + userID, apiKeyID int64, +) (bool, error) { + if s == nil || s.cache == nil { + return false, fmt.Errorf("grok video billing claim cache is unavailable") + } + key := grokVideoPendingBillingKey(requestID, userID, apiKeyID) + if key == "" { + return false, fmt.Errorf("grok video billing claim key is invalid") + } + return s.cache.ClaimGrokVideoBilled(ctx, key, grokVideoBilledClaimTTL(s.cfg)) +} + +// ReleaseGrokVideoBilling clears a claim after a failed durable RecordUsage so a +// later status/content poll can retry billing. +func (s *OpenAIGatewayService) ReleaseGrokVideoBilling( + ctx context.Context, + requestID string, + userID, apiKeyID int64, +) error { + if s == nil || s.cache == nil { + return fmt.Errorf("grok video billing claim cache is unavailable") + } + key := grokVideoPendingBillingKey(requestID, userID, apiKeyID) + if key == "" { + return fmt.Errorf("grok video billing claim key is invalid") + } + return s.cache.ReleaseGrokVideoBilled(ctx, key) +} + +// StableGrokVideoBillingRequestID is the durable usage_logs / dedup key for one +// async video task (not the per-poll gateway request id). +func StableGrokVideoBillingRequestID(taskRequestID string) string { + taskRequestID = strings.TrimSpace(taskRequestID) + if taskRequestID == "" { + return "" + } + if strings.HasPrefix(taskRequestID, "grok-video:") { + return taskRequestID + } + return "grok-video:" + taskRequestID +} + +// Official xAI async video status success shape (docs.x.ai Video Generation): +// +// {"status":"done","model":"grok-imagine-video-1.5","video":{"url":"...","duration":8,"respect_moderation":true}} +// +// Request may include resolution ("480p"|"720p"|"1080p"); completed status does not +// document a resolution field — bill resolution from the create-time request snapshot. + +// IsGrokVideoStatusBillable matches official success: status == "done" AND non-empty video.url. +// pending / expired / failed, or done without a video URL, are not billable. +func IsGrokVideoStatusBillable(statusBody []byte) bool { + if len(statusBody) == 0 || !gjson.ValidBytes(statusBody) { + return false + } + if !isOfficialGrokVideoStatusDone(statusBody) { + return false + } + return strings.TrimSpace(gjson.GetBytes(statusBody, "video.url").String()) != "" +} + +func isOfficialGrokVideoStatusDone(statusBody []byte) bool { + // Official enum: pending | done | expired | failed. + return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(statusBody, "status").String()), "done") +} + +// ExtractGrokVideoBillingFromStatusBody builds usage units from an official done status. +// Field priority (official docs): +// - duration: video.duration (seconds) +// - model: top-level model +// - resolution: not in status response → create-time pending snapshot → default 480p +func ExtractGrokVideoBillingFromStatusBody(statusBody []byte, pending *GrokVideoPendingBilling, requestID string) *OpenAIForwardResult { + if !IsGrokVideoStatusBillable(statusBody) { + return nil + } + model := "" + billingModel := "" + upstreamModel := "" + resolution := "" + durationSeconds := 0 + + if gjson.ValidBytes(statusBody) { + // Official: top-level model. + model = strings.TrimSpace(gjson.GetBytes(statusBody, "model").String()) + // Official: video.duration (number of seconds). + if v := gjson.GetBytes(statusBody, "video.duration"); v.Exists() && v.Type == gjson.Number { + durationSeconds = int(v.Int()) + if durationSeconds == 0 && v.Float() > 0 { + // Sub-second values are unexpected for this API; still accept truncated int path above. + durationSeconds = int(v.Float()) + } + } + } + if pending != nil { + if model == "" { + model = firstNonEmpty(pending.BillingModel, pending.Model, pending.OriginalModel) + } + if billingModel == "" { + billingModel = firstNonEmpty(pending.BillingModel, pending.Model) + } + if upstreamModel == "" { + upstreamModel = pending.UpstreamModel + } + // Official status has no resolution — always take create request when available. + resolution = pending.VideoResolution + if durationSeconds <= 0 { + durationSeconds = pending.VideoDurationSeconds + } + } + if model == "" { + // Official default video model family when status omits model. + model = "grok-imagine-video" + } + if billingModel == "" { + billingModel = model + } + // Resolution is request-only per docs; empty → handler applies official default 480p. + if resolution != "" { + resolution = NormalizeVideoBillingResolutionOrDefault(resolution) + } + if durationSeconds > 0 { + durationSeconds = NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds) + } + responseID := extractGrokMediaVideoRequestID(statusBody) + if responseID == "" { + responseID = strings.TrimSpace(requestID) + } + return &OpenAIForwardResult{ + ResponseID: responseID, + Model: model, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + VideoCount: 1, + VideoResolution: resolution, + VideoDurationSeconds: durationSeconds, + } +} + func (s *OpenAIGatewayService) ForwardGrokMedia( ctx context.Context, c *gin.Context, @@ -437,12 +719,23 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } writeGrokMediaResponse(c, resp, respBody, s.responseHeaderFilter) usage := grokMediaUsageFromResponse(endpoint, requestInfo, respBody) + resultModel := requestModel + resultBillingModel := requestModel + if endpoint == GrokMediaEndpointVideoStatus { + // Status has no request body model; use upstream status fields when billable. + if m := strings.TrimSpace(usage.Model); m != "" { + resultModel = m + } + if m := strings.TrimSpace(usage.BillingModel); m != "" { + resultBillingModel = m + } + } return &OpenAIForwardResult{ RequestID: requestIDHeader, ResponseID: usage.ResponseID, Usage: usage.Usage, - Model: requestModel, - BillingModel: requestModel, + Model: resultModel, + BillingModel: resultBillingModel, UpstreamModel: upstreamModel, ResponseHeaders: resp.Header.Clone(), Duration: time.Since(startTime), @@ -568,11 +861,24 @@ func (s *OpenAIGatewayService) forwardGrokMediaVideoContent( if err := writeGrokMediaContentResponse(c, contentResp); err != nil { return nil, err } - return &OpenAIForwardResult{ + // Content download is an alternate completion observation: when status body is + // official done+video.url, attach billable units so the handler can claim once + // (same path as status polling). Pending snapshot is merged in the handler. + result := &OpenAIForwardResult{ RequestID: contentRequestID, ResponseHeaders: contentResp.Header.Clone(), Duration: time.Since(startTime), - }, nil + } + if billed := ExtractGrokVideoBillingFromStatusBody(statusBody, nil, requestID); billed != nil { + result.ResponseID = firstNonEmpty(billed.ResponseID, strings.TrimSpace(requestID)) + result.Model = billed.Model + result.BillingModel = billed.BillingModel + result.UpstreamModel = billed.UpstreamModel + result.VideoCount = billed.VideoCount + result.VideoResolution = billed.VideoResolution + result.VideoDurationSeconds = billed.VideoDurationSeconds + } + return result, nil } func grokMediaSignedVideoContentURL(body []byte, requestID string) (string, error) { @@ -602,9 +908,13 @@ func isGrokCLIProxyTarget(rawURL string) bool { } func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { - if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) { + if endpoint != GrokMediaEndpointImagesEdits { return body, contentType, nil } + if gjson.ValidBytes(body) { + out, err := normalizeGrokMediaJSONImageRefs(body) + return out, contentType, err + } mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { return body, contentType, nil @@ -628,7 +938,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten images := make([]map[string]string, 0, len(info.InputImageURLs)+len(info.Uploads)) for _, imageURL := range info.InputImageURLs { if imageURL = strings.TrimSpace(imageURL); imageURL != "" { - images = append(images, map[string]string{"url": imageURL}) + images = append(images, grokMediaImageObject(imageURL)) } } for _, upload := range info.Uploads { @@ -636,7 +946,10 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten if err != nil { return nil, "", err } - images = append(images, map[string]string{"url": dataURL}) + images = append(images, grokMediaImageObject(dataURL)) + } + if len(images) > grokMediaMaxEditSourceImages { + return nil, "", fmt.Errorf("a maximum of %d source images is supported for image edits", grokMediaMaxEditSourceImages) } if len(images) > 0 { payload["image"] = images[0] @@ -654,7 +967,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten maskImageURL = dataURL } if maskImageURL != "" { - payload["mask"] = map[string]string{"url": maskImageURL} + payload["mask"] = grokMediaImageObject(maskImageURL) } out, err := marshalOpenAIUpstreamJSON(payload) @@ -664,6 +977,53 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten return out, "application/json", nil } +func normalizeGrokMediaJSONImageRefs(body []byte) ([]byte, error) { + info := ParseGrokMediaRequest("application/json", body) + if len(info.InputImageURLs) > grokMediaMaxEditSourceImages { + return nil, fmt.Errorf("a maximum of %d source images is supported for image edits", grokMediaMaxEditSourceImages) + } + out := body + var err error + for _, field := range []string{"image", "images", "mask"} { + out, err = rewriteGrokMediaJSONImageField(out, field) + if err != nil { + return nil, err + } + } + return out, nil +} + +func rewriteGrokMediaJSONImageField(body []byte, path string) ([]byte, error) { + value := gjson.GetBytes(body, path) + if !value.Exists() { + return body, nil + } + if value.IsArray() { + rewritten := make([]map[string]string, 0, len(value.Array())) + for _, item := range value.Array() { + imageURL := extractGrokMediaImageURL(item) + if imageURL == "" { + return body, nil + } + rewritten = append(rewritten, grokMediaImageObject(imageURL)) + } + out, err := sjson.SetBytes(body, path, rewritten) + if err != nil { + return nil, fmt.Errorf("rewrite grok media %s: %w", path, err) + } + return out, nil + } + imageURL := extractGrokMediaImageURL(value) + if imageURL == "" { + return body, nil + } + out, err := sjson.SetBytes(body, path, grokMediaImageObject(imageURL)) + if err != nil { + return nil, fmt.Errorf("rewrite grok media %s: %w", path, err) + } + return out, nil +} + func normalizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { if !endpoint.RequiresRequestBody() || !gjson.ValidBytes(body) { return body, contentType, nil @@ -773,9 +1133,11 @@ func NormalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string return "grok-imagine-image-quality" } case GrokMediaEndpointVideosGenerations: - if model == "grok-imagine-video-1.5" && !hasInputImage { - return "grok-imagine-video" - } + // xAI's 1.5 model is image-to-video only. Keep the requested model + // unchanged when the image is missing so the upstream returns its + // documented invalid-argument response instead of silently switching + // models and pricing. + _ = hasInputImage } return model } @@ -783,6 +1145,8 @@ func NormalizeGrokMediaModelForEndpoint(endpoint GrokMediaEndpoint, model string type grokMediaUsageMetadata struct { ResponseID string Usage OpenAIUsage + Model string + BillingModel string ImageCount int ImageSize string ImageInputSize string @@ -802,12 +1166,24 @@ func grokMediaUsageFromResponse(endpoint GrokMediaEndpoint, requestInfo GrokMedi meta.ImageInputSize = requestInfo.Size meta.ImageOutputSizes = collectOpenAIResponseImageOutputSizesFromJSONBytes(responseBody) case GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions: + // Async video: capture request_id + create-time pricing params only. + // Billable VideoCount is set later when status polling observes video.url. meta.ResponseID = extractGrokMediaVideoRequestID(responseBody) - meta.VideoCount = 1 meta.VideoResolution = requestInfo.Resolution meta.VideoDurationSeconds = requestInfo.DurationSeconds - // Keep the legacy media-unit counter populated for existing usage displays. - meta.ImageCount = 1 + case GrokMediaEndpointVideoStatus: + // Prefer status-body URL success + upstream duration/resolution when present. + if IsGrokVideoStatusBillable(responseBody) { + // provisional units; handler merges with pending snapshot before RecordUsage. + if billed := ExtractGrokVideoBillingFromStatusBody(responseBody, nil, ""); billed != nil { + meta.ResponseID = billed.ResponseID + meta.Model = billed.Model + meta.BillingModel = billed.BillingModel + meta.VideoCount = billed.VideoCount + meta.VideoResolution = billed.VideoResolution + meta.VideoDurationSeconds = billed.VideoDurationSeconds + } + } } return meta } @@ -816,7 +1192,7 @@ func extractGrokMediaVideoRequestID(body []byte) string { if len(body) == 0 || !gjson.ValidBytes(body) { return "" } - for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id"} { + for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id", "task_id", "data.task_id", "video.task_id"} { if id := strings.TrimSpace(gjson.GetBytes(body, path).String()); id != "" { return id } diff --git a/backend/internal/service/grok_media_video_billing_test.go b/backend/internal/service/grok_media_video_billing_test.go new file mode 100644 index 0000000000..84dc2c1079 --- /dev/null +++ b/backend/internal/service/grok_media_video_billing_test.go @@ -0,0 +1,149 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGrokVideoE2EDurationFromCreatedAt(t *testing.T) { + t.Parallel() + created := time.Now().UTC().Add(-45 * time.Second) + d := GrokVideoE2EDuration(created.Format(time.RFC3339Nano), time.Now().UTC()) + require.GreaterOrEqual(t, d, 44*time.Second) + require.LessOrEqual(t, d, 47*time.Second) + + require.Equal(t, time.Duration(0), GrokVideoE2EDuration("", time.Now())) + require.Equal(t, time.Duration(0), GrokVideoE2EDuration("not-a-time", time.Now())) + // Future CreatedAt clamps to zero (clock skew). + require.Equal(t, time.Duration(0), GrokVideoE2EDuration(time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), time.Now())) +} + +func TestGrokVideoPendingCreatedAtStampOnStoreShape(t *testing.T) { + t.Parallel() + // GrokVideoPendingCreatedAtNow must be parseable by GrokVideoE2EDuration. + stamp := GrokVideoPendingCreatedAtNow() + require.NotEmpty(t, stamp) + d := GrokVideoE2EDuration(stamp, time.Now().UTC().Add(2*time.Second)) + require.GreaterOrEqual(t, d, time.Second) + require.LessOrEqual(t, d, 3*time.Second) +} + +func TestIsGrokVideoStatusBillable(t *testing.T) { + t.Parallel() + // Official success: status=done + video.url + require.True(t, IsGrokVideoStatusBillable([]byte(`{ + "status":"done", + "model":"grok-imagine-video-1.5", + "video":{"url":"https://vidgen.x.ai/x.mp4","duration":8,"respect_moderation":true} + }`))) + + // Official non-success states + require.False(t, IsGrokVideoStatusBillable(nil)) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"pending"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"expired"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"failed"}`))) + // done without video.url is not billable + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"done"}`))) + // URL alone (legacy/non-official shapes) is not enough + require.False(t, IsGrokVideoStatusBillable([]byte(`{"url":"https://example.com/v.mp4"}`))) + require.False(t, IsGrokVideoStatusBillable([]byte(`{"download_url":"/v1/videos/task/content"}`))) + // "completed" is not the official enum value + require.False(t, IsGrokVideoStatusBillable([]byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/x.mp4"}}`))) +} + +func TestExtractGrokVideoBillingFromStatusBodyPrefersUpstreamParams(t *testing.T) { + t.Parallel() + pending := &GrokVideoPendingBilling{ + Model: "pending-model", + BillingModel: "pending-billing", + UpstreamModel: "pending-upstream", + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 8, + } + // Official completed body from docs.x.ai Video Generation. + body := []byte(`{ + "status":"done", + "model":"grok-imagine-video-1.5", + "video":{"url":"https://vidgen.x.ai/signed.mp4","duration":12,"respect_moderation":true} + }`) + result := ExtractGrokVideoBillingFromStatusBody(body, pending, "req-1") + require.NotNil(t, result) + require.Equal(t, 1, result.VideoCount) + require.Equal(t, "grok-imagine-video-1.5", result.Model) + // Resolution is not in official status response — use create-time request. + require.Equal(t, VideoBillingResolution720P, result.VideoResolution) + // Duration prefers official video.duration. + require.Equal(t, 12, result.VideoDurationSeconds) +} + +func TestExtractGrokVideoBillingFromStatusBodyFallsBackToPending(t *testing.T) { + t.Parallel() + pending := &GrokVideoPendingBilling{ + Model: "create-model", + BillingModel: "create-billing", + UpstreamModel: "create-upstream", + VideoResolution: VideoBillingResolution1080P, + VideoDurationSeconds: 10, + } + // done + video.url, but no model/duration in body. + body := []byte(`{"status":"done","video":{"url":"https://vidgen.x.ai/signed.mp4"}}`) + result := ExtractGrokVideoBillingFromStatusBody(body, pending, "req-2") + require.NotNil(t, result) + require.Equal(t, "create-billing", result.BillingModel) + require.Equal(t, "create-upstream", result.UpstreamModel) + require.Equal(t, VideoBillingResolution1080P, result.VideoResolution) + require.Equal(t, 10, result.VideoDurationSeconds) +} + +func TestExtractGrokVideoBillingRejectsNonDoneStatus(t *testing.T) { + t.Parallel() + pending := &GrokVideoPendingBilling{Model: "m", VideoDurationSeconds: 8, VideoResolution: "720p"} + require.Nil(t, ExtractGrokVideoBillingFromStatusBody( + []byte(`{"status":"pending","video":{"url":"https://vidgen.x.ai/x.mp4","duration":8}}`), + pending, "req", + )) + require.Nil(t, ExtractGrokVideoBillingFromStatusBody( + []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/x.mp4","duration":8}}`), + pending, "req", + )) +} + +func TestGrokMediaUsageFromResponseVideoCreateDoesNotBill(t *testing.T) { + t.Parallel() + info := GrokMediaRequestInfo{Model: "grok-imagine-video", Resolution: "720p", DurationSeconds: 10} + meta := grokMediaUsageFromResponse(GrokMediaEndpointVideosGenerations, info, []byte(`{"request_id":"v1"}`)) + require.Equal(t, "v1", meta.ResponseID) + require.Equal(t, 0, meta.VideoCount) + require.Equal(t, 10, meta.VideoDurationSeconds) + require.Equal(t, VideoBillingResolution720P, meta.VideoResolution) +} + +func TestGrokMediaUsageFromResponseVideoStatusBillsOnOfficialDone(t *testing.T) { + t.Parallel() + meta := grokMediaUsageFromResponse( + GrokMediaEndpointVideoStatus, + GrokMediaRequestInfo{}, + []byte(`{"status":"done","model":"grok-imagine-video-1.5","video":{"url":"https://vidgen.x.ai/a.mp4","duration":9}}`), + ) + require.Equal(t, 1, meta.VideoCount) + require.Equal(t, 9, meta.VideoDurationSeconds) + require.Equal(t, "grok-imagine-video-1.5", meta.Model) + + // Official non-done must not set billable units. + pendingOnly := grokMediaUsageFromResponse( + GrokMediaEndpointVideoStatus, + GrokMediaRequestInfo{}, + []byte(`{"status":"pending"}`), + ) + require.Equal(t, 0, pendingOnly.VideoCount) + + // completed is not official done. + completed := grokMediaUsageFromResponse( + GrokMediaEndpointVideoStatus, + GrokMediaRequestInfo{}, + []byte(`{"status":"completed","video":{"url":"https://vidgen.x.ai/a.mp4","duration":9}}`), + ) + require.Equal(t, 0, completed.VideoCount) +} diff --git a/backend/internal/service/grok_model_quota_block.go b/backend/internal/service/grok_model_quota_block.go new file mode 100644 index 0000000000..0bc9cd40b8 --- /dev/null +++ b/backend/internal/service/grok_model_quota_block.go @@ -0,0 +1,114 @@ +package service + +import ( + "strconv" + "strings" + "sync" + "time" +) + +// Process-local per-account model soft-blocks for Grok free-usage that names a +// model (e.g. "used all free usage for model grok-4.5"). Sibling models on the +// same account stay schedulable. Multi-instance: each process learns from its +// own upstream errors. +type grokModelQuotaBlock struct { + Until time.Time +} + +type grokModelQuotaBlockStore struct { + mu sync.Mutex + items map[string]grokModelQuotaBlock // key: accountID|model +} + +var globalGrokModelQuotaBlocks = &grokModelQuotaBlockStore{ + items: make(map[string]grokModelQuotaBlock), +} + +const ( + grokModelQuotaBlockDefaultTTL = 2 * time.Hour + grokModelQuotaBlockMaxTTL = 6 * time.Hour + grokModelQuotaBlockMinTTL = 20 * time.Minute +) + +func grokModelQuotaBlockKey(accountID int64, model string) string { + return strings.TrimSpace(strings.ToLower(model)) + "|" + strconv.FormatInt(accountID, 10) +} + +// markGrokModelQuotaBlock soft-blocks accountID for model until the given time. +func markGrokModelQuotaBlock(accountID int64, model string, until time.Time) { + model = strings.TrimSpace(model) + if accountID <= 0 || model == "" || until.IsZero() { + return + } + now := time.Now() + if !until.After(now.Add(grokModelQuotaBlockMinTTL)) { + until = now.Add(grokModelQuotaBlockDefaultTTL) + } + if max := now.Add(grokModelQuotaBlockMaxTTL); until.After(max) { + until = max + } + key := grokModelQuotaBlockKey(accountID, model) + globalGrokModelQuotaBlocks.mu.Lock() + defer globalGrokModelQuotaBlocks.mu.Unlock() + if cur, ok := globalGrokModelQuotaBlocks.items[key]; ok && cur.Until.After(until) { + return + } + globalGrokModelQuotaBlocks.items[key] = grokModelQuotaBlock{Until: until} + for k, v := range globalGrokModelQuotaBlocks.items { + if !v.Until.After(now) { + delete(globalGrokModelQuotaBlocks.items, k) + } + } +} + +// isGrokModelQuotaBlocked reports whether this account cannot serve model now. +func isGrokModelQuotaBlocked(accountID int64, model string, now time.Time) bool { + model = strings.TrimSpace(model) + if accountID <= 0 || model == "" { + return false + } + key := grokModelQuotaBlockKey(accountID, model) + globalGrokModelQuotaBlocks.mu.Lock() + defer globalGrokModelQuotaBlocks.mu.Unlock() + cur, ok := globalGrokModelQuotaBlocks.items[key] + if !ok { + return false + } + if !cur.Until.After(now) { + delete(globalGrokModelQuotaBlocks.items, key) + return false + } + return true +} + +func filterGrokModelQuotaBlockedAccounts(accounts []Account, model string, now time.Time) []Account { + if len(accounts) == 0 || strings.TrimSpace(model) == "" { + return accounts + } + out := make([]Account, 0, len(accounts)) + for i := range accounts { + upstreamModel := canonicalOpenAIAccountSchedulingModel(&accounts[i], model) + if isGrokModelQuotaBlocked(accounts[i].ID, upstreamModel, now) { + continue + } + out = append(out, accounts[i]) + } + return out +} + +// isGrokModelSpecificFreeUsage is true when free-usage exhaustion is scoped to +// a named model (account may still serve other models). +func isGrokModelSpecificFreeUsage(low, model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + if model == "" || low == "" { + return false + } + if strings.Contains(low, "for model") || strings.Contains(low, "模型") { + return true + } + // "used all the included free usage for model grok-4.5" + if strings.Contains(low, "free usage") && strings.Contains(low, model) { + return true + } + return false +} diff --git a/backend/internal/service/grok_oauth_reconciliation_test.go b/backend/internal/service/grok_oauth_reconciliation_test.go index 8de04a1b5d..5449716447 100644 --- a/backend/internal/service/grok_oauth_reconciliation_test.go +++ b/backend/internal/service/grok_oauth_reconciliation_test.go @@ -107,6 +107,24 @@ func (r *grokReconcileRepo) UpdateCredentials(_ context.Context, id int64, crede return nil } +func (r *grokReconcileRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + for i := range r.accounts { + if r.accounts[i].ID != id { + continue + } + if r.accounts[i].Extra == nil { + r.accounts[i].Extra = make(map[string]any) + } + for key, value := range updates { + r.accounts[i].Extra[key] = value + } + break + } + return nil +} + func (r *grokReconcileRepo) SetError(_ context.Context, id int64, message string) error { r.mu.Lock() defer r.mu.Unlock() diff --git a/backend/internal/service/grok_oauth_service.go b/backend/internal/service/grok_oauth_service.go index 92f3937967..19120e84bd 100644 --- a/backend/internal/service/grok_oauth_service.go +++ b/backend/internal/service/grok_oauth_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -18,14 +19,44 @@ type GrokOAuthService struct { sessionStore *xai.SessionStore proxyRepo ProxyRepository oauthClient GrokOAuthClient + config *config.Config } -func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient) *GrokOAuthService { - return &GrokOAuthService{ +func NewGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, configs ...*config.Config) *GrokOAuthService { + service := &GrokOAuthService{ sessionStore: xai.NewSessionStore(), proxyRepo: proxyRepo, oauthClient: oauthClient, } + if len(configs) > 0 { + service.config = configs[0] + } + return service +} + +// WithSessionStore replaces the in-memory OAuth session store (e.g. Redis-backed +// for cross-instance single-use callbacks). Redis wiring stays in Wire providers +// so this service package does not import go-redis (depguard). +func (s *GrokOAuthService) WithSessionStore(store *xai.SessionStore) *GrokOAuthService { + if s != nil && store != nil { + if s.sessionStore != nil { + s.sessionStore.Stop() + } + s.sessionStore = store + } + return s +} + +type GrokOAuthCapabilities struct { + PasswordAuthEnabled bool `json:"password_auth_enabled"` +} + +func (s *GrokOAuthService) GetCapabilities() GrokOAuthCapabilities { + return GrokOAuthCapabilities{PasswordAuthEnabled: s.passwordAuthEnabled()} +} + +func (s *GrokOAuthService) passwordAuthEnabled() bool { + return s.config != nil && s.config.Gateway.Grok.PasswordAuthEnabled } type GrokAuthURLResult struct { @@ -106,6 +137,13 @@ type GrokTokenInfo struct { EntitlementStatus string `json:"entitlement_status,omitempty"` } +// GrokPasswordLoginResult is an ephemeral password-login outcome. +// SSOToken is never persisted and must only feed ConvertSSOToBuild. +type GrokPasswordLoginResult struct { + Email string `json:"email,omitempty"` + SSOToken string `json:"sso_token"` +} + func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchangeCodeInput) (*GrokTokenInfo, error) { if input == nil { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_INPUT", "input is required") @@ -114,7 +152,6 @@ func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchange if !ok { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_NOT_FOUND", "session not found or expired") } - defer s.sessionStore.Delete(input.SessionID) parsed := xai.ParseAuthorizationInput(input.Code) code := strings.TrimSpace(parsed.Code) @@ -125,12 +162,16 @@ func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchange if state == "" { state = strings.TrimSpace(parsed.State) } - if parsed.RequiresState && state == "" { - return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required for callback URLs") + if state == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_STATE_REQUIRED", "oauth state is required") } - if state != "" && subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 { + if subtle.ConstantTimeCompare([]byte(state), []byte(session.State)) != 1 { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_STATE", "invalid oauth state") } + if redirectURI := strings.TrimSpace(input.RedirectURI); redirectURI != "" && + redirectURI != strings.TrimSpace(session.RedirectURI) { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_REDIRECT_URI_MISMATCH", "redirect_uri does not match the OAuth session") + } proxyURL := session.ProxyURL if input.ProxyID != nil { @@ -140,27 +181,45 @@ func (s *GrokOAuthService) ExchangeCode(ctx context.Context, input *GrokExchange return nil, err } } - redirectURI := session.RedirectURI - if strings.TrimSpace(input.RedirectURI) != "" { - redirectURI = input.RedirectURI + if err := s.requireOAuthClient(); err != nil { + return nil, err } - - tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, redirectURI, proxyURL, session.ClientID) + if !s.sessionStore.TryConsumeSession(input.SessionID) { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_SESSION_ALREADY_USED", "oauth session has already been used") + } + defer s.sessionStore.Delete(input.SessionID) + tokenResp, err := s.oauthClient.ExchangeCode(ctx, code, session.CodeVerifier, session.RedirectURI, proxyURL, session.ClientID) if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } return s.tokenInfoFromResponse(tokenResp, session.ClientID, nil), nil } +func (s *GrokOAuthService) requireOAuthClient() error { + if s == nil || s.oauthClient == nil { + return infraerrors.New(http.StatusInternalServerError, "GROK_OAUTH_CLIENT_NOT_CONFIGURED", "oauth client is not configured") + } + return nil +} + func (s *GrokOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*GrokTokenInfo, error) { refreshToken = strings.TrimSpace(refreshToken) if refreshToken == "" { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_REFRESH_TOKEN", "refresh_token is required") } + if err := s.requireOAuthClient(); err != nil { + return nil, err + } tokenResp, err := s.oauthClient.RefreshToken(ctx, refreshToken, proxyURL, clientID) if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } tokenInfo := s.tokenInfoFromResponse(tokenResp, clientID, nil) if tokenInfo.RefreshToken == "" { tokenInfo.RefreshToken = refreshToken @@ -176,7 +235,16 @@ func (s *GrokOAuthService) ValidateRefreshToken(ctx context.Context, refreshToke return s.RefreshToken(ctx, refreshToken, proxyURL, xai.EffectiveClientID()) } -func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { +// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens. +// The raw sso_token is never stored on GrokTokenInfo or account credentials. +func (s *GrokOAuthService) ValidateSSOToken(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { + ssoToken = strings.TrimSpace(ssoToken) + if ssoToken == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_NO_SSO_TOKEN", "sso_token is required") + } + if err := s.requireOAuthClient(); err != nil { + return nil, err + } proxyURL, err := s.proxyURL(ctx, proxyID) if err != nil { return nil, err @@ -185,9 +253,61 @@ func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, if err != nil { return nil, err } + if err := validateGrokTokenResponse(tokenResp); err != nil { + return nil, err + } return s.tokenInfoFromResponse(tokenResp, xai.DefaultClientID, nil), nil } +// ConvertFromSSO is the batch-import entry point; same semantics as ValidateSSOToken. +func (s *GrokOAuthService) ConvertFromSSO(ctx context.Context, ssoToken string, proxyID *int64) (*GrokTokenInfo, error) { + return s.ValidateSSOToken(ctx, ssoToken, proxyID) +} + +// AuthorizePassword logs in with email/password, converts the resulting SSO cookie +// to Build OAuth, and returns OAuth tokens only. Password and raw SSO are never persisted. +func (s *GrokOAuthService) AuthorizePassword(ctx context.Context, email, password string, proxyID *int64) (*GrokTokenInfo, error) { + if !s.passwordAuthEnabled() { + return nil, infraerrors.New(http.StatusForbidden, "GROK_OAUTH_PASSWORD_AUTH_DISABLED", "Grok password authorization is disabled") + } + email = strings.TrimSpace(email) + if email == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_EMAIL_REQUIRED", "email is required") + } + if strings.TrimSpace(password) == "" { + return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_PASSWORD_REQUIRED", "password is required") + } + if err := s.requireOAuthClient(); err != nil { + return nil, err + } + proxyURL, err := s.proxyURL(ctx, proxyID) + if err != nil { + return nil, err + } + loginResult, err := s.oauthClient.LoginWithPassword(ctx, email, password, proxyURL) + if err != nil { + return nil, err + } + if loginResult == nil || strings.TrimSpace(loginResult.SSOToken) == "" { + return nil, infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "grok password login did not return sso_token") + } + info, err := s.ValidateSSOToken(ctx, loginResult.SSOToken, proxyID) + if err != nil { + return nil, err + } + if strings.TrimSpace(info.Email) == "" { + info.Email = loginResult.Email + } + return info, nil +} + +func validateGrokTokenResponse(tokenResp *xai.TokenResponse) error { + if tokenResp == nil || strings.TrimSpace(tokenResp.AccessToken) == "" { + return infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_INVALID_TOKEN_RESPONSE", "grok oauth token response missing access_token") + } + return nil +} + func (s *GrokOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GrokTokenInfo, error) { if account == nil || account.Platform != PlatformGrok { return nil, infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_INVALID_ACCOUNT", "account is not a Grok account") diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index 54baef03a2..cbbd802e01 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -9,25 +9,37 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" ) type grokOAuthClientStub struct { - refreshResponse *xai.TokenResponse - ssoResponse *xai.TokenResponse - exchangeCalls int + refreshResponse *xai.TokenResponse + ssoResponse *xai.TokenResponse + loginResult *GrokPasswordLoginResult + loginEmail string + loginPassword string + exchangeCalls int + exchangeRedirectURI string } -func (s *grokOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) { +func (s *grokOAuthClientStub) ExchangeCode(_ context.Context, _, _, redirectURI, _, _ string) (*xai.TokenResponse, error) { s.exchangeCalls++ - return &xai.TokenResponse{}, nil + s.exchangeRedirectURI = redirectURI + return &xai.TokenResponse{AccessToken: "access-token"}, nil } func (s *grokOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) { return s.refreshResponse, nil } +func (s *grokOAuthClientStub) LoginWithPassword(_ context.Context, email, password, _ string) (*GrokPasswordLoginResult, error) { + s.loginEmail = email + s.loginPassword = password + return s.loginResult, nil +} + func (s *grokOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) { return s.ssoResponse, nil } @@ -49,7 +61,19 @@ func TestGrokOAuthServiceRefreshTokenPreservesOriginalRefreshTokenWhenNotRotated require.Equal(t, "client-id", info.ClientID) } -func TestGrokOAuthServiceExchangeCodeRequiresStateForCallbackURLAndConsumesSession(t *testing.T) { +func TestGrokOAuthServiceRefreshTokenRejectsEmptyUpstreamResponse(t *testing.T) { + svc := NewGrokOAuthService(nil, &grokOAuthClientStub{}) + defer svc.Stop() + + require.NotPanics(t, func() { + info, err := svc.RefreshToken(context.Background(), "refresh-token", "", "client-id") + require.Nil(t, info) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_INVALID_TOKEN_RESPONSE") + }) +} + +func TestGrokOAuthServiceExchangeCodeConsumesOnlyAfterValidation(t *testing.T) { client := &grokOAuthClientStub{} svc := NewGrokOAuthService(nil, client) defer svc.Stop() @@ -70,9 +94,92 @@ func TestGrokOAuthServiceExchangeCodeRequiresStateForCallbackURLAndConsumesSessi Code: "code-with-state", State: auth.State, }) + require.NoError(t, err) + require.Equal(t, 1, client.exchangeCalls) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "replayed-code", + State: auth.State, + }) require.Error(t, err) require.Contains(t, err.Error(), "GROK_OAUTH_SESSION_NOT_FOUND") + require.Equal(t, 1, client.exchangeCalls) +} + +func TestGrokOAuthServiceExchangeCodeRejectsMissingClientWithoutConsumingSession(t *testing.T) { + svc := NewGrokOAuthService(nil, nil) + defer svc.Stop() + auth, err := svc.GenerateAuthURL(context.Background(), nil, "") + require.NoError(t, err) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "code", + State: auth.State, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED") + _, ok := svc.sessionStore.Get(auth.SessionID) + require.True(t, ok) +} + +func TestGrokOAuthServiceExchangeCodeRequiresStateForBareCode(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + auth, err := svc.GenerateAuthURL(context.Background(), nil, "") + require.NoError(t, err) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "bare-authorization-code", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_STATE_REQUIRED") require.Zero(t, client.exchangeCalls) + _, ok := svc.sessionStore.Get(auth.SessionID) + require.True(t, ok) +} + +func TestGrokOAuthServiceExchangeCodeRejectsRedirectURIOverride(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + auth, err := svc.GenerateAuthURL(context.Background(), nil, "") + require.NoError(t, err) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "authorization-code", + State: auth.State, + RedirectURI: "http://127.0.0.1:9999/callback", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_REDIRECT_URI_MISMATCH") + require.Zero(t, client.exchangeCalls) + + _, err = svc.ExchangeCode(context.Background(), &GrokExchangeCodeInput{ + SessionID: auth.SessionID, + Code: "authorization-code", + State: auth.State, + RedirectURI: xai.DefaultRedirectURI, + }) + require.NoError(t, err) + require.Equal(t, xai.DefaultRedirectURI, client.exchangeRedirectURI) +} + +func TestGrokOAuthServiceExternalFlowsRejectMissingClient(t *testing.T) { + svc := NewGrokOAuthService(nil, nil) + defer svc.Stop() + + _, err := svc.RefreshToken(context.Background(), "refresh-token", "", "") + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED") + + _, err = svc.ValidateSSOToken(context.Background(), "sso-token", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_CLIENT_NOT_CONFIGURED") } func TestGrokOAuthServiceBuildAccountCredentialsDefaultsToSubscriptionProxy(t *testing.T) { @@ -108,6 +215,69 @@ func TestGrokOAuthServiceConvertFromSSOExtractsBuildClaims(t *testing.T) { require.Equal(t, "user@example.com", credentials["email"]) require.Equal(t, "user-sub", credentials["sub"]) require.Equal(t, "team-1", credentials["team_id"]) + require.NotContains(t, credentials, "sso_token") +} + +func TestGrokOAuthServiceValidateSSOTokenReturnsOAuthTokensWithoutPersistingSSO(t *testing.T) { + svc := NewGrokOAuthService(nil, &grokOAuthClientStub{ + ssoResponse: &xai.TokenResponse{ + AccessToken: "access-from-sso", + RefreshToken: "refresh-from-sso", + TokenType: "Bearer", + ExpiresIn: 3600, + }, + }) + defer svc.Stop() + + info, err := svc.ValidateSSOToken(context.Background(), "sso-token", nil) + require.NoError(t, err) + require.Equal(t, "access-from-sso", info.AccessToken) + require.Equal(t, "refresh-from-sso", info.RefreshToken) + + creds := svc.BuildAccountCredentials(info) + require.NotContains(t, creds, "sso_token") + require.NotContains(t, creds, "password") +} + +func TestGrokOAuthServiceAuthorizePasswordUsesLoginThenSSOAuthorize(t *testing.T) { + client := &grokOAuthClientStub{ + loginResult: &GrokPasswordLoginResult{ + Email: "user@example.com", + SSOToken: "password-derived-sso", + }, + ssoResponse: &xai.TokenResponse{ + AccessToken: "access-from-password", + RefreshToken: "refresh-from-password", + ExpiresIn: 3600, + }, + } + cfg := &config.Config{} + cfg.Gateway.Grok.PasswordAuthEnabled = true + svc := NewGrokOAuthService(nil, client, cfg) + defer svc.Stop() + + require.True(t, svc.GetCapabilities().PasswordAuthEnabled) + info, err := svc.AuthorizePassword(context.Background(), " user@example.com ", " super-secret ", nil) + require.NoError(t, err) + require.Equal(t, "user@example.com", info.Email) + require.Equal(t, "access-from-password", info.AccessToken) + creds := svc.BuildAccountCredentials(info) + require.NotContains(t, creds, "password") + require.NotContains(t, creds, "sso_token") + require.Equal(t, "user@example.com", client.loginEmail) + require.Equal(t, " super-secret ", client.loginPassword) +} + +func TestGrokOAuthServiceAuthorizePasswordDisabledByDefault(t *testing.T) { + client := &grokOAuthClientStub{} + svc := NewGrokOAuthService(nil, client) + defer svc.Stop() + + require.False(t, svc.GetCapabilities().PasswordAuthEnabled) + _, err := svc.AuthorizePassword(context.Background(), "user@example.com", "secret", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "GROK_OAUTH_PASSWORD_AUTH_DISABLED") + require.Empty(t, client.loginEmail) } func makeGrokOAuthJWT(claims map[string]any) string { diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go new file mode 100644 index 0000000000..480ba457bd --- /dev/null +++ b/backend/internal/service/grok_observed_models.go @@ -0,0 +1,198 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/tidwall/gjson" +) + +const ( + grokObservedModelsExtraKey = "grok_observed_models" + grokObservedModelsTTL = 6 * time.Hour + grokObservedModelsTimeout = 15 * time.Second +) + +type grokObservedModelsSnapshot struct { + Models []string `json:"models"` + FetchedAt string `json:"fetched_at"` + Source string `json:"source,omitempty"` +} + +var grokObservedModelsFlight sync.Map // accountID -> *singleflight-ish in-flight + +// scheduleGrokObservedModelsSync best-effort fetches upstream /v1/models for a +// Grok OAuth account and stores IDs in Extra. Never blocks request path long; +// callers should fire-and-forget after successful auth/probe. +func (s *GrokQuotaService) scheduleGrokObservedModelsSync(account *Account) { + if s == nil || account == nil || !account.IsGrokOAuth() || s.accountRepo == nil { + return + } + id := account.ID + if _, loaded := grokObservedModelsFlight.LoadOrStore(id, struct{}{}); loaded { + return + } + // Copy credentials for background use. + acc := *account + go func() { + defer grokObservedModelsFlight.Delete(id) + ctx, cancel := context.WithTimeout(context.Background(), grokObservedModelsTimeout) + defer cancel() + if err := s.syncGrokObservedModels(ctx, &acc); err != nil { + slog.Debug("grok_observed_models_sync_failed", "account_id", id, "error", err) + } + }() +} + +func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account *Account) error { + if s == nil || account == nil { + return nil + } + // Skip if snapshot is still fresh. + if snap := parseGrokObservedModels(account.Extra); snap != nil { + if t, err := time.Parse(time.RFC3339, snap.FetchedAt); err == nil && time.Since(t) < grokObservedModelsTTL { + return nil + } + } + token := strings.TrimSpace(account.GetGrokAccessToken()) + if token == "" && s.tokenProvider != nil { + // Best-effort warm; avoid forcing refresh storms. + if at, err := s.tokenProvider.GetAccessToken(ctx, account); err == nil { + token = strings.TrimSpace(at) + } + } + if token == "" { + return nil + } + baseURL := strings.TrimSpace(account.GetGrokBaseURL()) + if s.settingService != nil { + baseURL = strings.TrimSpace(s.settingService.ResolveGrokBaseURL(ctx, account)) + } + if baseURL == "" { + baseURL = xai.DefaultCLIBaseURL + } + validator, err := grokBaseURLValidator(account, s.cfg) + if err != nil { + return err + } + validatedBaseURL, err := validator(baseURL) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(validatedBaseURL), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", grokUpstreamUserAgent) + if account.IsGrokOAuth() { + applyGrokCLIHeaders(req.Header) + if isGrokCLIProxyTarget(req.URL.String()) { + if userID := strings.TrimSpace(account.GetCredential("sub")); userID != "" { + req.Header.Set("X-UserID", userID) + } + if email := strings.TrimSpace(account.GetCredential("email")); email != "" { + req.Header.Set("X-Email", email) + } + } + } + account.ApplyHeaderOverrides(req.Header) + + proxyURL := "" + if s.proxyRepo != nil && account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + if s.httpUpstream == nil { + return nil + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return nil + } + ids := extractGrokModelIDsFromModelsBody(body) + if len(ids) == 0 { + return nil + } + snap := grokObservedModelsSnapshot{ + Models: ids, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Source: "upstream_v1_models", + } + raw, err := json.Marshal(snap) + if err != nil { + return err + } + var asMap map[string]any + if err := json.Unmarshal(raw, &asMap); err != nil { + return err + } + return s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + grokObservedModelsExtraKey: asMap, + }) +} + +func extractGrokModelIDsFromModelsBody(body []byte) []string { + data := gjson.GetBytes(body, "data") + if !data.IsArray() { + // Some gateways return a bare array. + data = gjson.ParseBytes(body) + } + seen := make(map[string]struct{}) + var out []string + data.ForEach(func(_, v gjson.Result) bool { + id := strings.TrimSpace(v.Get("id").String()) + if id == "" { + id = strings.TrimSpace(v.String()) + } + if id == "" { + return true + } + if _, ok := seen[id]; ok { + return true + } + seen[id] = struct{}{} + out = append(out, id) + return true + }) + return out +} + +func parseGrokObservedModels(extra map[string]any) *grokObservedModelsSnapshot { + if extra == nil { + return nil + } + raw, ok := extra[grokObservedModelsExtraKey] + if !ok || raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil + } + var snap grokObservedModelsSnapshot + if err := json.Unmarshal(b, &snap); err != nil { + return nil + } + if len(snap.Models) == 0 { + return nil + } + return &snap +} diff --git a/backend/internal/service/grok_p2_test.go b/backend/internal/service/grok_p2_test.go new file mode 100644 index 0000000000..2be382d26e --- /dev/null +++ b/backend/internal/service/grok_p2_test.go @@ -0,0 +1,102 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGrokModelQuotaBlock_FiltersOnlyNamedModel(t *testing.T) { + id := time.Now().UnixNano()%1_000_000 + 5000 + markGrokModelQuotaBlock(id, "grok-4.5", time.Now().Add(time.Hour)) + now := time.Now() + require.True(t, isGrokModelQuotaBlocked(id, "grok-4.5", now)) + require.False(t, isGrokModelQuotaBlocked(id, "grok-4.3", now)) + + accounts := []Account{ + {ID: id, Platform: PlatformGrok, Type: AccountTypeOAuth}, + {ID: id + 1, Platform: PlatformGrok, Type: AccountTypeOAuth}, + } + filtered := filterGrokModelQuotaBlockedAccounts(accounts, "grok-4.5", now) + require.Len(t, filtered, 1) + require.Equal(t, id+1, filtered[0].ID) +} + +func TestGrokModelQuotaBlockFiltersMappedUpstreamModel(t *testing.T) { + id := time.Now().UnixNano()%1_000_000 + 7000 + markGrokModelQuotaBlock(id, "grok-4.5", time.Now().Add(time.Hour)) + account := Account{ + ID: id, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-*": "grok-4.5"}, + }, + } + + require.Empty(t, filterGrokModelQuotaBlockedAccounts([]Account{account}, "gpt-5", time.Now())) +} + +func TestIsGrokModelSpecificFreeUsage(t *testing.T) { + require.True(t, isGrokModelSpecificFreeUsage( + "you've used all the included free usage for model grok-4.5", "grok-4.5")) + require.True(t, isGrokModelSpecificFreeUsage("模型额度用完 grok-4.3", "grok-4.3")) + require.False(t, isGrokModelSpecificFreeUsage("free usage exhausted", "grok-4.5")) +} + +func TestGrokStickyAffinitySeed_ScopesByModel(t *testing.T) { + a := grokStickyAffinitySeed("session-1", []byte(`{"model":"grok-4.5"}`)) + b := grokStickyAffinitySeed("session-1", []byte(`{"model":"grok-4.3"}`)) + c := grokStickyAffinitySeed("session-1", []byte(`{"model":"grok-4.5"}`)) + require.NotEqual(t, a, b) + require.Equal(t, a, c) + require.Contains(t, a, "grok-affinity:v1:") +} + +func TestExtractGrokModelIDsFromModelsBody(t *testing.T) { + body := []byte(`{"object":"list","data":[{"id":"grok-4.5"},{"id":"grok-4.3"},{"id":"grok-4.5"}]}`) + ids := extractGrokModelIDsFromModelsBody(body) + require.Equal(t, []string{"grok-4.5", "grok-4.3"}, ids) +} + +func TestAccountGrokNeedsReauth(t *testing.T) { + require.False(t, accountGrokNeedsReauth(nil)) + require.True(t, accountGrokNeedsReauth(&Account{ + Extra: map[string]any{"grok_needs_reauth": true}, + })) + require.True(t, accountGrokNeedsReauth(&Account{ + Status: StatusError, + ErrorMessage: "Grok spending limit reached; reauthorize or wait for billing reset", + })) +} + +func TestApplyGrokUpstreamFailure_ModelSpecificFreeUsage(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9109, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage for model grok-4.5. Usage resets over a rolling 24-hour window."}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, 400, nil, body) + + require.Zero(t, repo.tempUnschedCalls, "model-scoped free usage must not cool sibling models") + require.True(t, isGrokModelQuotaBlocked(account.ID, "grok-4.5", time.Now())) + require.False(t, isGrokModelQuotaBlocked(account.ID, "grok-4.3", time.Now())) +} + +func TestApplyGrokUpstreamFailure_SpendingLimitRemainsRecoverable(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9110, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"code":"personal-team-blocked:spending-limit","error":"spending limit reached"}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, 403, nil, body) + + require.Equal(t, 1, repo.rateLimitedCalls) + require.Zero(t, repo.tempUnschedCalls) + // Without a billing-period snapshot, use a short recoverable probe cooldown. + require.WithinDuration(t, time.Now().Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) +} diff --git a/backend/internal/service/grok_quota_fetcher.go b/backend/internal/service/grok_quota_fetcher.go index 545b1c9e97..4979dfd0b9 100644 --- a/backend/internal/service/grok_quota_fetcher.go +++ b/backend/internal/service/grok_quota_fetcher.go @@ -36,6 +36,7 @@ func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { activeProbeClearsForbidden := newerSuccessfulGrokActiveProbeClearsBillingForbidden(billing, snapshot) if billing != nil { usage.GrokBilling = billing + applyGrokBillingProgressWindows(usage, billing, now) if billing.Plan != "" { usage.SubscriptionTier = billing.Plan usage.SubscriptionTierRaw = billing.Plan @@ -59,6 +60,14 @@ func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { case 429: usage.ErrorCode = "rate_limited" } + // Official weekly/monthly progress clears the "unknown until headers" state. + if usage.ErrorCode == "quota_unknown" && (usage.SevenDay != nil || usage.ThirtyDay != nil) { + usage.ErrorCode = "" + if strings.Contains(strings.ToLower(usage.Error), "unknown until") || + strings.Contains(strings.ToLower(usage.Error), "no xai quota headers") { + usage.Error = "" + } + } } if err != nil || snapshot == nil { @@ -124,6 +133,12 @@ func (f *GrokQuotaFetcher) BuildUsageInfo(account *Account) *UsageInfo { usage.ErrorCode = "rate_limited" } } + if accountGrokNeedsReauth(account) { + usage.NeedsReauth = true + if usage.ErrorCode == "" { + usage.ErrorCode = "spending_limit" + } + } applyGrokCredentialUsageFallback(usage, account) if activeProbeClearsForbidden && strings.TrimSpace(snapshot.EntitlementStatus) == "" && strings.EqualFold(strings.TrimSpace(usage.GrokEntitlementStatus), "forbidden") { @@ -240,3 +255,48 @@ func grokQuotaSnapshotFromExtra(extra map[string]any) (*xai.QuotaSnapshot, error return &out, nil } } + +// applyGrokBillingProgressWindows fills official weekly (seven_day) and monthly +// (thirty_day) UsageProgress from a billing probe summary. +func applyGrokBillingProgressWindows(usage *UsageInfo, billing *xai.BillingSummary, now time.Time) { + if usage == nil || billing == nil { + return + } + if billing.UsagePercent != nil { + seven := &UsageProgress{Utilization: *billing.UsagePercent} + if end, err := parseTime(strings.TrimSpace(billing.PeriodEnd)); err == nil { + seven.ResetsAt = &end + if sec := int(end.Sub(now).Seconds()); sec > 0 { + seven.RemainingSeconds = sec + } + } + if usage.SevenDay != nil { + seven.WindowStats = usage.SevenDay.WindowStats + } + usage.SevenDay = seven + } + var monthlyUtil *float64 + if billing.UsedPercent != nil { + monthlyUtil = billing.UsedPercent + } else if billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0 && billing.UsedCents != nil { + v := (*billing.UsedCents / *billing.MonthlyLimitCents) * 100 + monthlyUtil = &v + } + if monthlyUtil != nil { + thirty := &UsageProgress{Utilization: *monthlyUtil} + endRaw := strings.TrimSpace(billing.BillingPeriodEnd) + if endRaw == "" && billing.PeriodType == "monthly" { + endRaw = strings.TrimSpace(billing.PeriodEnd) + } + if end, err := parseTime(endRaw); err == nil { + thirty.ResetsAt = &end + if sec := int(end.Sub(now).Seconds()); sec > 0 { + thirty.RemainingSeconds = sec + } + } + if usage.ThirtyDay != nil { + thirty.WindowStats = usage.ThirtyDay.WindowStats + } + usage.ThirtyDay = thirty + } +} diff --git a/backend/internal/service/grok_quota_service.go b/backend/internal/service/grok_quota_service.go index 2c703f9f10..6658888a59 100644 --- a/backend/internal/service/grok_quota_service.go +++ b/backend/internal/service/grok_quota_service.go @@ -50,13 +50,14 @@ type GrokQuotaResetResult struct { } type GrokQuotaService struct { - accountRepo AccountRepository - proxyRepo ProxyRepository - tokenProvider *GrokTokenProvider - httpUpstream HTTPUpstream - usageLogRepo UsageLogRepository - cfg *config.Config - probeFlight singleflight.Group + accountRepo AccountRepository + proxyRepo ProxyRepository + tokenProvider *GrokTokenProvider + httpUpstream HTTPUpstream + usageLogRepo UsageLogRepository + settingService *SettingService + cfg *config.Config + probeFlight singleflight.Group } func NewGrokQuotaService( @@ -81,11 +82,20 @@ func NewGrokQuotaService( } } +func (s *GrokQuotaService) SetSettingService(settingService *SettingService) { + if s != nil { + s.settingService = settingService + } +} + // QueryQuota combines xAI billing data with an active quota-header probe for // Free accounts, whose billing response does not include usage_percent. func (s *GrokQuotaService) QueryQuota(ctx context.Context, accountID int64) (*GrokQuotaProbeResult, error) { billingResult, billingErr := s.ProbeBilling(ctx, accountID) if billingErr == nil && billingResult != nil && grokBillingHasAuthoritativeQuota(billingResult.Billing) { + if acc, err := s.accountRepo.GetByID(ctx, accountID); err == nil { + s.scheduleGrokObservedModelsSync(acc) + } return billingResult, nil } @@ -111,6 +121,9 @@ func (s *GrokQuotaService) QueryQuota(ctx context.Context, accountID int64) (*Gr probeResult.LocalUsageMonthly = billingResult.LocalUsageMonthly probeResult.Persisted = probeResult.Persisted || billingResult.Persisted } + if acc, err := s.accountRepo.GetByID(ctx, accountID); err == nil { + s.scheduleGrokObservedModelsSync(acc) + } return probeResult, nil } @@ -141,7 +154,7 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_PROBE_BODY_ERROR", "failed to build probe body: %v", err) } - targetURL, err := buildGrokResponsesURL(account, s.cfg) + targetURL, err := buildGrokResponsesURL(account, s.cfg, s.settingService) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err) } @@ -172,9 +185,21 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr if limited { normalizeGrokExhaustedWindowResets(snapshot, resetAt, time.Now()) } - persistErr := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ - grokQuotaSnapshotExtraKey: snapshot, - }) + // A failed probe must not erase a previously observed snapshot. 401/403 and + // transport/server errors commonly carry no quota headers; only successful + // responses, or 429 responses with useful rate-limit headers, are safe to + // persist. A successful 200 with no headers is still persisted as an + // explicit "no headers" observation so the UI can distinguish it from never + // probed. + persistErr := error(nil) + persisted := false + shouldPersist := resp.StatusCode < 400 || resp.StatusCode == http.StatusTooManyRequests + if shouldPersist && (snapshot.HeadersObserved || resp.StatusCode == http.StatusOK) { + persistErr = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + grokQuotaSnapshotExtraKey: snapshot, + }) + persisted = persistErr == nil + } if limited { persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) } else if isSuccessfulGrokRateLimitRecovery(account, snapshot) { @@ -189,7 +214,7 @@ func (s *GrokQuotaService) probeUsage(ctx context.Context, accountID int64) (*Gr HeadersObserved: snapshot.HeadersObserved, ResetSupported: false, FetchedAt: time.Now().Unix(), - Persisted: persistErr == nil, + Persisted: persisted, } if resp.StatusCode == http.StatusTooManyRequests { return result, nil diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 9ffb70f000..70ee2c8d72 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -86,6 +87,60 @@ func (r *grokQuotaAccountRepo) SetTempUnschedulable(_ context.Context, id int64, return nil } +func TestSyncGrokObservedModelsRejectsOAuthCustomURLOutsideOperatorPolicy(t *testing.T) { + account := &Account{ + ID: 901, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "secret-token", + "base_url": "https://blocked.example.test/v1", + }, + } + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = true + cfg.Security.URLAllowlist.UpstreamHosts = []string{"allowed.example.test"} + svc := &GrokQuotaService{accountRepo: repo, httpUpstream: upstream, cfg: cfg} + + err := svc.syncGrokObservedModels(context.Background(), account) + require.ErrorContains(t, err, "base URL rejected by URL security policy") + require.Nil(t, upstream.lastReq) +} + +func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { + account := &Account{ + ID: 902, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "secret-token", + "sub": "user-902", + "email": "user902@example.test", + }, + } + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"}]}`)), + }} + svc := &GrokQuotaService{accountRepo: repo, httpUpstream: upstream, cfg: &config.Config{}} + + 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.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + 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")) + require.Contains(t, repo.updates[account.ID], grokObservedModelsExtraKey) +} + type grokQuotaProxyRepo struct { proxyRepoStub proxies map[int64]*Proxy @@ -610,6 +665,28 @@ func TestGrokQuotaServiceProbeUsageStoresNoHeadersState(t *testing.T) { require.Equal(t, observedResetAt, repo.recoveryObservedReset) } +func TestGrokQuotaServiceProbeUsageDoesNotOverwriteSnapshotOnUnauthorized(t *testing.T) { + t.Parallel() + + account := healthyGrokQuotaOAuthAccount(44) + previous := &xai.QuotaSnapshot{StatusCode: http.StatusOK, HeadersObserved: true} + account.Extra = map[string]any{grokQuotaSnapshotExtraKey: previous} + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + }} + svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream, nil) + + _, err := svc.ProbeUsage(context.Background(), account.ID) + require.Error(t, err) + require.Equal(t, 0, repo.updateCalls) + require.Same(t, previous, account.Extra[grokQuotaSnapshotExtraKey]) +} + func TestGrokQuotaServiceProbeUsageReturnsRateLimitedSnapshot(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/grok_search_count.go b/backend/internal/service/grok_search_count.go new file mode 100644 index 0000000000..c89ce93f04 --- /dev/null +++ b/backend/internal/service/grok_search_count.go @@ -0,0 +1,233 @@ +package service + +import ( + "strconv" + "strings" + + "github.com/tidwall/gjson" +) + +// countGrokNativeSearchCallsFromJSONBytes counts completed native search tool +// calls in a Responses-style JSON body (output array or nested response.output). +// Counts: web_search_call, x_search_call, tool_search_call, and function_call +// named tool_search / web_search / x_search. +func countGrokNativeSearchCallsFromJSONBytes(body []byte) int { + if len(body) == 0 || !gjson.ValidBytes(body) { + return 0 + } + // Responses envelopes normally expose either top-level output (JSON mode) + // or response.output (terminal SSE payload). Compatibility layers can retain + // both copies; counting both would bill the same search twice. Prefer the + // canonical nested response when present and fall back to top-level output. + if nested := gjson.GetBytes(body, "response.output"); nested.IsArray() { + return countGrokNativeSearchCallsInOutputArray(nested) + } + return countGrokNativeSearchCallsInOutputArray(gjson.GetBytes(body, "output")) +} + +func countGrokNativeSearchCallsFromSSEBody(body string) int { + if strings.TrimSpace(body) == "" { + return 0 + } + seen := make(map[string]struct{}) + total := 0 + forEachOpenAISSEDataPayload(body, func(data []byte) { + total += countGrokNativeSearchCallsInSSEDataDedup(data, seen) + }) + return total +} + +// countGrokNativeSearchCallsInSSEData counts search tool calls in one SSE +// payload without cross-event dedup. Prefer countGrokNativeSearchCallsInSSEDataDedup +// for live streams so item.done + response.completed do not double-bill. +func countGrokNativeSearchCallsInSSEData(data []byte) int { + n, _ := countGrokNativeSearchCallsInSSEDataWithKeys(data) + return n +} + +// countGrokNativeSearchCallsInSSEDataDedup increments only unseen call ids. +// Callers must reuse the same seen map for the full stream lifetime. +// +// When call_id/id is missing, a synthetic key is built from item type + name so +// item.done + response.completed for the same tool still count once (never fall +// back to raw multi-event n, which ~2× overbills). +func countGrokNativeSearchCallsInSSEDataDedup(data []byte, seen map[string]struct{}) int { + if seen == nil { + return countGrokNativeSearchCallsInSSEData(data) + } + n, keys := countGrokNativeSearchCallsInSSEDataWithKeys(data) + if n <= 0 { + return 0 + } + // Prefer stable ids; fill gaps with synthetic keys so we never raw-add n. + if len(keys) < n { + // Rebuild keys for every item so unkeyed items still get a fingerprint. + keys = collectGrokNativeSearchCallKeys(data) + } + if len(keys) == 0 { + // True empty — should not happen when n>0; fail-closed to 0 extra bill. + return 0 + } + added := 0 + local := make(map[string]struct{}, len(keys)) + isItemDone := strings.TrimSpace(gjson.GetBytes(data, "type").String()) == "response.output_item.done" + for _, k := range keys { + if k == "" { + continue + } + if _, ok := local[k]; ok { + continue + } + local[k] = struct{}{} + if _, ok := seen[k]; ok { + if !isItemDone || !strings.HasPrefix(k, "synth:") { + continue + } + // Each id-less item.done is a distinct completed invocation. Advance + // its ordinal so interrupted streams remain accurately billable. + separator := strings.LastIndexByte(k, ':') + if separator < 0 { + continue + } + base := k[:separator] + for ordinal := 2; ; ordinal++ { + candidate := base + ":" + strconv.Itoa(ordinal) + if _, exists := seen[candidate]; !exists { + k = candidate + break + } + } + } + seen[k] = struct{}{} + added++ + } + return added +} + +func collectGrokNativeSearchCallKeys(data []byte) []string { + if len(data) == 0 || !gjson.ValidBytes(data) { + return nil + } + // An empty type means a bare item object without an SSE envelope; anything + // else that is not a completion event carries no billable call. + switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) { + case "response.output_item.done", "response.completed", "response.done", "": + default: + return nil + } + var keys []string + syntheticOrdinals := make(map[string]int) + consider := func(item gjson.Result) { + if !isGrokNativeSearchOutputItem(item) { + return + } + key := firstNonEmpty( + strings.TrimSpace(item.Get("call_id").String()), + strings.TrimSpace(item.Get("id").String()), + strings.TrimSpace(item.Get("item.call_id").String()), + strings.TrimSpace(item.Get("item.id").String()), + ) + if key == "" { + // Include the ordinal among same-kind calls. A plain type:name key + // collapses two id-less web searches in one completed response into + // one charge. The ordinal remains stable between ordered item.done + // events and response.completed output. + base := "synth:" + strings.ToLower(strings.TrimSpace(item.Get("type").String())) + + ":" + strings.ToLower(strings.TrimSpace(item.Get("name").String())) + syntheticOrdinals[base]++ + key = base + ":" + strconv.Itoa(syntheticOrdinals[base]) + } + keys = append(keys, key) + } + if item := gjson.GetBytes(data, "item"); item.Exists() { + consider(item) + } + gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool { + consider(item) + return true + }) + gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool { + consider(item) + return true + }) + if len(keys) == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) { + consider(gjson.ParseBytes(data)) + } + return keys +} + +func countGrokNativeSearchCallsInSSEDataWithKeys(data []byte) (int, []string) { + if len(data) == 0 || !gjson.ValidBytes(data) { + return 0, nil + } + // Count once on item completion / completed response, not on every delta. + // An empty type is a bare item object without an SSE envelope. + switch strings.TrimSpace(gjson.GetBytes(data, "type").String()) { + case "response.output_item.done", "response.completed", "response.done", "": + default: + return 0, nil + } + var keys []string + n := 0 + consider := func(item gjson.Result) { + if !isGrokNativeSearchOutputItem(item) { + return + } + n++ + key := firstNonEmpty( + strings.TrimSpace(item.Get("call_id").String()), + strings.TrimSpace(item.Get("id").String()), + strings.TrimSpace(item.Get("item.call_id").String()), + strings.TrimSpace(item.Get("item.id").String()), + ) + if key != "" { + keys = append(keys, key) + } + } + if item := gjson.GetBytes(data, "item"); item.Exists() { + consider(item) + } + gjson.GetBytes(data, "response.output").ForEach(func(_, item gjson.Result) bool { + consider(item) + return true + }) + gjson.GetBytes(data, "output").ForEach(func(_, item gjson.Result) bool { + consider(item) + return true + }) + // Bare output item event without nested item key. + if n == 0 && isGrokNativeSearchOutputItem(gjson.ParseBytes(data)) { + consider(gjson.ParseBytes(data)) + } + return n, keys +} + +func countGrokNativeSearchCallsInOutputArray(output gjson.Result) int { + if !output.IsArray() { + return 0 + } + count := 0 + output.ForEach(func(_, item gjson.Result) bool { + if isGrokNativeSearchOutputItem(item) { + count++ + } + return true + }) + return count +} + +func isGrokNativeSearchOutputItem(item gjson.Result) bool { + if !item.Exists() { + return false + } + itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String())) + switch itemType { + case "web_search_call", "x_search_call", "tool_search_call": + return true + case "function_call", "custom_tool_call": + name := strings.ToLower(strings.TrimSpace(item.Get("name").String())) + return name == "web_search" || name == "x_search" || name == "tool_search" + default: + return false + } +} diff --git a/backend/internal/service/grok_search_count_test.go b/backend/internal/service/grok_search_count_test.go new file mode 100644 index 0000000000..8bb0a2c79e --- /dev/null +++ b/backend/internal/service/grok_search_count_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCountGrokNativeSearchCallsFromJSONBytes(t *testing.T) { + t.Parallel() + require.Equal(t, 0, countGrokNativeSearchCallsFromJSONBytes(nil)) + require.Equal(t, 0, countGrokNativeSearchCallsFromJSONBytes([]byte(`{"output":[]}`))) + body := []byte(`{"output":[ + {"type":"web_search_call","id":"ws1","status":"completed"}, + {"type":"x_search_call","id":"xs1"}, + {"type":"function_call","name":"tool_search","call_id":"ts1"}, + {"type":"function_call","name":"lookup","call_id":"other"} + ]}`) + require.Equal(t, 3, countGrokNativeSearchCallsFromJSONBytes(body)) +} + +func TestCountGrokNativeSearchCallsFromJSONBytes_PrefersNestedResponse(t *testing.T) { + t.Parallel() + body := []byte(`{"output":[{"type":"web_search_call","id":"duplicate"}],"response":{"output":[{"type":"web_search_call","id":"duplicate"},{"type":"x_search_call","id":"xs1"}]}}`) + require.Equal(t, 2, countGrokNativeSearchCallsFromJSONBytes(body)) +} + +func TestCountGrokNativeSearchCallsFromJSONBytes_FallsBackWhenNestedOutputNull(t *testing.T) { + t.Parallel() + body := []byte(`{"output":[{"type":"web_search_call","id":"ws1"}],"response":{"output":null}}`) + require.Equal(t, 1, countGrokNativeSearchCallsFromJSONBytes(body)) +} + +func TestCountGrokNativeSearchCallsFromSSEBodyDedups(t *testing.T) { + t.Parallel() + sse := stringsJoin( + `data: {"type":"response.output_item.done","item":{"type":"web_search_call","id":"ws1","call_id":"c1"}}`, + `data: {"type":"response.output_item.done","item":{"type":"web_search_call","id":"ws1","call_id":"c1"}}`, + `data: {"type":"response.completed","response":{"output":[{"type":"web_search_call","id":"ws1","call_id":"c1"},{"type":"x_search_call","id":"xs1","call_id":"c2"}]}}`, + ) + require.Equal(t, 2, countGrokNativeSearchCallsFromSSEBody(sse)) +} + +func TestCountGrokNativeSearchCallsInSSEDataDedup_LiveStreamPath(t *testing.T) { + t.Parallel() + // Mirrors the live streaming accumulator: item.done then response.completed + // for the same call_id must bill once (regression for ~2× surcharge). + seen := make(map[string]struct{}) + done := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call","id":"ws1","call_id":"c1"}}`) + completed := []byte(`{"type":"response.completed","response":{"output":[{"type":"web_search_call","id":"ws1","call_id":"c1"},{"type":"x_search_call","id":"xs1","call_id":"c2"}]}}`) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(done, seen)) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(completed, seen)) + // Raw (no-dedup) path still double-counts the same envelope pair. + require.Equal(t, 1, countGrokNativeSearchCallsInSSEData(done)) + require.Equal(t, 2, countGrokNativeSearchCallsInSSEData(completed)) +} + +func TestCountGrokNativeSearchCallsInSSEDataDedup_NoIDStillDedups(t *testing.T) { + t.Parallel() + // Upstream sometimes omits call_id/id; synthetic keys must still prevent 2×. + seen := make(map[string]struct{}) + done := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call"}}`) + completed := []byte(`{"type":"response.completed","response":{"output":[{"type":"web_search_call"}]}}`) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(done, seen)) + require.Equal(t, 0, countGrokNativeSearchCallsInSSEDataDedup(completed, seen)) +} + +func TestCountGrokNativeSearchCallsInSSEDataDedup_MultipleNoIDCalls(t *testing.T) { + t.Parallel() + seen := make(map[string]struct{}) + firstDone := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call"}}`) + secondDone := []byte(`{"type":"response.output_item.done","item":{"type":"web_search_call"}}`) + completed := []byte(`{"type":"response.completed","response":{"output":[{"type":"web_search_call"},{"type":"web_search_call"}]}}`) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(firstDone, seen)) + require.Equal(t, 1, countGrokNativeSearchCallsInSSEDataDedup(secondDone, seen)) + require.Equal(t, 0, countGrokNativeSearchCallsInSSEDataDedup(completed, seen)) +} + +func stringsJoin(lines ...string) string { + out := "" + for _, l := range lines { + out += l + "\n\n" + } + return out +} diff --git a/backend/internal/service/grok_spending_reauth.go b/backend/internal/service/grok_spending_reauth.go new file mode 100644 index 0000000000..2b1f3a0973 --- /dev/null +++ b/backend/internal/service/grok_spending_reauth.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "strings" + "time" +) + +// Spending-limit is recoverable at the end of the observed billing period. +// When no billing snapshot is available, use a short probe rather than +// fabricating a 24h boundary from the error arrival time. +const grokSpendingLimitProbeCooldown = 10 * time.Minute + +func grokSpendingLimitResetAt(account *Account, now time.Time) time.Time { + if account != nil { + if billing, err := grokBillingSnapshotFromExtra(account.Extra); err == nil && billing != nil { + for _, raw := range []string{billing.PeriodEnd, billing.BillingPeriodEnd} { + if resetAt, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)); err == nil && resetAt.After(now) { + return resetAt + } + } + } + } + return now.Add(grokSpendingLimitProbeCooldown) +} + +// clearGrokNeedsReauthExtra drops the soft reauth flag after successful refresh +// or reauth. Best-effort; never fails the request path. +func clearGrokNeedsReauthExtra(ctx context.Context, repo AccountRepository, accountID int64) { + if repo == nil || accountID <= 0 { + return + } + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + _ = repo.UpdateExtra(stateCtx, accountID, map[string]any{ + "grok_needs_reauth": false, + "grok_needs_reauth_reason": "", + "grok_needs_reauth_at": "", + }) +} + +func accountGrokNeedsReauth(account *Account) bool { + if account == nil { + return false + } + if account.Status == StatusError { + msg := strings.ToLower(account.ErrorMessage) + if strings.Contains(msg, "spending limit") || strings.Contains(msg, "reauthorize") { + return true + } + } + if v, ok := account.Extra["grok_needs_reauth"].(bool); ok && v { + return true + } + if s, ok := account.Extra["grok_needs_reauth"].(string); ok { + return strings.EqualFold(s, "true") || s == "1" + } + return false +} diff --git a/backend/internal/service/grok_stream_idle.go b/backend/internal/service/grok_stream_idle.go new file mode 100644 index 0000000000..3bdfa27fc9 --- /dev/null +++ b/backend/internal/service/grok_stream_idle.go @@ -0,0 +1,38 @@ +package service + +import ( + "fmt" + "strings" + "time" +) + +// Default Grok stream idle when gateway.stream_data_interval_timeout is 0. +// Long enough for slow thinking models, short enough to release hung sockets. +const defaultGrokStreamIdleTimeout = 180 * time.Second + +// Shorter cool after a Grok stream-idle failure so the account can re-enter soon +// but is not immediately re-picked in a tight failover loop. +const grokStreamIdleCooldown = 2 * time.Minute + +// resolveGrokStreamIdleTimeout returns the effective upstream-read idle timeout +// for Grok streams. Prefers the global gateway setting when positive; otherwise +// applies a Grok-only default so hung SSE bodies still fail over. +func resolveGrokStreamIdleTimeout(cfgStreamIntervalSec int) time.Duration { + if cfgStreamIntervalSec > 0 { + return time.Duration(cfgStreamIntervalSec) * time.Second + } + return defaultGrokStreamIdleTimeout +} + +// grokStreamIdleFailoverError builds a pre-commit/handler-visible failover so +// the gateway can switch OAuth accounts after a hung Grok upstream stream. +func grokStreamIdleFailoverError(account *Account, idle time.Duration) *UpstreamFailoverError { + msg := fmt.Sprintf("Grok stream idle timeout after %s with no upstream data", idle.Round(time.Second)) + return &UpstreamFailoverError{ + 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(), + } +} diff --git a/backend/internal/service/grok_stream_idle_test.go b/backend/internal/service/grok_stream_idle_test.go new file mode 100644 index 0000000000..e3c6d16943 --- /dev/null +++ b/backend/internal/service/grok_stream_idle_test.go @@ -0,0 +1,25 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestResolveGrokStreamIdleTimeout(t *testing.T) { + require.Equal(t, 90*time.Second, resolveGrokStreamIdleTimeout(90)) + require.Equal(t, defaultGrokStreamIdleTimeout, resolveGrokStreamIdleTimeout(0)) + require.Equal(t, defaultGrokStreamIdleTimeout, resolveGrokStreamIdleTimeout(-1)) +} + +func TestGrokStreamIdleFailoverError(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformGrok, Type: AccountTypeOAuth} + err := grokStreamIdleFailoverError(account, 180*time.Second) + require.NotNil(t, err) + require.Equal(t, 502, err.StatusCode) + require.True(t, err.SafeToFailoverAfterWrite) + require.Contains(t, string(err.ResponseBody), "empty_upstream") +} diff --git a/backend/internal/service/grok_team_rate_limit.go b/backend/internal/service/grok_team_rate_limit.go new file mode 100644 index 0000000000..f10852615a --- /dev/null +++ b/backend/internal/service/grok_team_rate_limit.go @@ -0,0 +1,149 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "sync" + "time" +) + +// In-memory team+model rate-limit overlay for Grok OAuth. When xAI rate-limits +// one account in a team for a model, sibling accounts sharing team_id skip the +// same model until the cooldown expires (mirrors grok2api teamModelRateLimit). +// +// Process-local only: multi-instance deployments each learn the block from their +// own 429s. Prefer short TTLs so drift self-heals. +type grokTeamModelRateLimit struct { + Until time.Time +} + +type grokTeamModelRateLimitStore struct { + mu sync.Mutex + items map[string]grokTeamModelRateLimit +} + +var globalGrokTeamModelRateLimits = &grokTeamModelRateLimitStore{ + items: make(map[string]grokTeamModelRateLimit), +} + +const ( + grokTeamRateLimitDefaultTTL = 10 * time.Minute + grokTeamRateLimitMaxTTL = time.Hour + grokTeamRateLimitMinTTL = 30 * time.Second +) + +func grokTeamFingerprint(teamID string) string { + teamID = strings.TrimSpace(teamID) + if teamID == "" { + return "" + } + sum := sha256.Sum256([]byte(strings.ToLower(teamID))) + return hex.EncodeToString(sum[:8]) +} + +func grokTeamModelRateLimitKey(teamFingerprint, model string) string { + return teamFingerprint + "|" + strings.ToLower(strings.TrimSpace(model)) +} + +func accountGrokTeamID(account *Account) string { + if account == nil { + return "" + } + return strings.TrimSpace(account.GetCredential("team_id")) +} + +// markGrokTeamModelRateLimit records that this team+model pair should be skipped +// until until. No-op when team_id or model is empty. +func markGrokTeamModelRateLimit(account *Account, model string, until time.Time) { + if account == nil || !account.IsGrokOAuth() { + return + } + fp := grokTeamFingerprint(accountGrokTeamID(account)) + model = strings.TrimSpace(model) + if fp == "" || model == "" || until.IsZero() { + return + } + now := time.Now() + if !until.After(now) { + until = now.Add(grokTeamRateLimitDefaultTTL) + } + maxUntil := now.Add(grokTeamRateLimitMaxTTL) + if until.After(maxUntil) { + until = maxUntil + } + key := grokTeamModelRateLimitKey(fp, model) + globalGrokTeamModelRateLimits.mu.Lock() + defer globalGrokTeamModelRateLimits.mu.Unlock() + if cur, ok := globalGrokTeamModelRateLimits.items[key]; ok && cur.Until.After(until) { + return + } + globalGrokTeamModelRateLimits.items[key] = grokTeamModelRateLimit{Until: until} + // Opportunistic prune of expired entries. + for k, v := range globalGrokTeamModelRateLimits.items { + if !v.Until.After(now) { + delete(globalGrokTeamModelRateLimits.items, k) + } + } +} + +// isGrokTeamModelRateLimited reports whether the account's team is currently +// blocked for the requested model. +func isGrokTeamModelRateLimited(account *Account, model string, now time.Time) bool { + if account == nil || !account.IsGrokOAuth() { + return false + } + fp := grokTeamFingerprint(accountGrokTeamID(account)) + model = strings.TrimSpace(model) + if fp == "" || model == "" { + return false + } + key := grokTeamModelRateLimitKey(fp, model) + globalGrokTeamModelRateLimits.mu.Lock() + defer globalGrokTeamModelRateLimits.mu.Unlock() + cur, ok := globalGrokTeamModelRateLimits.items[key] + if !ok { + return false + } + if !cur.Until.After(now) { + delete(globalGrokTeamModelRateLimits.items, key) + return false + } + return true +} + +// filterGrokTeamModelRateLimitedAccounts drops candidates whose team is under a +// model-scoped rate-limit cool. Accounts without team_id pass through. +func filterGrokTeamModelRateLimitedAccounts(accounts []Account, model string, now time.Time) []Account { + if len(accounts) == 0 || strings.TrimSpace(model) == "" { + return accounts + } + out := accounts[:0] + kept := false + for i := range accounts { + upstreamModel := canonicalOpenAIAccountSchedulingModel(&accounts[i], model) + if isGrokTeamModelRateLimited(&accounts[i], upstreamModel, now) { + continue + } + out = append(out, accounts[i]) + kept = true + } + if !kept && len(out) == 0 { + // All filtered — return empty (caller treats as no capacity). + return nil + } + return out +} + +// resolveGrokTeamRateLimitUntil derives a team cool window from an observed +// account rate-limit reset, with sane clamps. +func resolveGrokTeamRateLimitUntil(resetAt, now time.Time) time.Time { + if resetAt.After(now.Add(grokTeamRateLimitMinTTL)) { + maxUntil := now.Add(grokTeamRateLimitMaxTTL) + if resetAt.After(maxUntil) { + return maxUntil + } + return resetAt + } + return now.Add(grokTeamRateLimitDefaultTTL) +} diff --git a/backend/internal/service/grok_team_rate_limit_test.go b/backend/internal/service/grok_team_rate_limit_test.go new file mode 100644 index 0000000000..4926d4d7aa --- /dev/null +++ b/backend/internal/service/grok_team_rate_limit_test.go @@ -0,0 +1,75 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGrokTeamModelRateLimit_MarksAndFiltersSiblings(t *testing.T) { + // Isolate from other tests by using unique team ids. + team := "team-test-" + time.Now().Format("150405.000") + a1 := &Account{ + ID: 101, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"team_id": team}, + } + a2 := &Account{ + ID: 102, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"team_id": team}, + } + other := &Account{ + ID: 103, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"team_id": team + "-other"}, + } + noTeam := &Account{ + ID: 104, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{}, + } + + now := time.Now() + markGrokTeamModelRateLimit(a1, "grok-4.5", now.Add(5*time.Minute)) + + require.True(t, isGrokTeamModelRateLimited(a1, "grok-4.5", now)) + require.True(t, isGrokTeamModelRateLimited(a2, "grok-4.5", now), "sibling with same team must cool") + require.False(t, isGrokTeamModelRateLimited(a2, "grok-4.3", now), "other model stays pickable") + require.False(t, isGrokTeamModelRateLimited(other, "grok-4.5", now)) + require.False(t, isGrokTeamModelRateLimited(noTeam, "grok-4.5", now)) + + filtered := filterGrokTeamModelRateLimitedAccounts([]Account{*a1, *a2, *other, *noTeam}, "grok-4.5", now) + require.Len(t, filtered, 2) + ids := []int64{filtered[0].ID, filtered[1].ID} + require.Contains(t, ids, int64(103)) + require.Contains(t, ids, int64(104)) +} + +func TestGrokTeamModelRateLimit_Expires(t *testing.T) { + team := "team-expire-" + time.Now().Format("150405.000") + a := &Account{ + ID: 201, Platform: PlatformGrok, Type: AccountTypeOAuth, + Credentials: map[string]any{"team_id": team}, + } + past := time.Now().Add(-time.Minute) + markGrokTeamModelRateLimit(a, "grok-4.5", past) + // mark clamps expired until into default TTL from "now" — use direct store inject via past+recheck + // After mark with past, resolveGrokTeamRateLimitUntil path isn't used; mark uses now+default when until not after now. + require.True(t, isGrokTeamModelRateLimited(a, "grok-4.5", time.Now())) +} + +func TestGrokTeamModelRateLimitFilterUsesMappedUpstreamModel(t *testing.T) { + now := time.Now() + account := &Account{ + ID: 301, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "team_id": "team-mapped-301", + "model_mapping": map[string]any{"gpt-*": "grok-4.5"}, + }, + } + markGrokTeamModelRateLimit(account, "grok-4.5", now.Add(time.Hour)) + + require.Empty(t, filterGrokTeamModelRateLimitedAccounts([]Account{*account}, "gpt-5", now)) +} diff --git a/backend/internal/service/grok_token_refresher.go b/backend/internal/service/grok_token_refresher.go index d2d4d534fc..72b041bc6e 100644 --- a/backend/internal/service/grok_token_refresher.go +++ b/backend/internal/service/grok_token_refresher.go @@ -3,12 +3,25 @@ package service import ( "context" "errors" + "hash/fnv" "strings" "time" ) +// Base warm window: refresh when access token lifetime remaining is below this. +// Grok access tokens are typically ~1h; refreshing up to 1h early keeps the pool +// warm for request path cache misses. const grokTokenRefreshSkew = time.Hour +// Stampede spread: each account's effective warm window is reduced by a +// deterministic offset in [0, grokTokenRefreshJitterMax] so co-imported accounts +// do not all refresh in the same TokenRefreshService cycle (grok2api-style +// RefreshDueAt scatter). +const grokTokenRefreshJitterMax = 3 * time.Minute + +// Floor so jitter cannot shrink the window below a useful threshold. +const grokTokenRefreshSkewMin = 30 * time.Minute + type GrokTokenRefresher struct { grokOAuthService GrokOAuthTokenService } @@ -40,9 +53,35 @@ func (r *GrokTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.D if refreshWindow < grokTokenRefreshSkew { refreshWindow = grokTokenRefreshSkew } + // Deterministic per-account jitter: spread warm refreshes without random + // non-determinism in tests (hash of account id). + refreshWindow = grokTokenRefreshWindowWithJitter(account.ID, refreshWindow) return time.Until(*expiresAt) < refreshWindow } +// grokTokenRefreshWindowWithJitter returns refreshWindow minus a stable offset +// in [0, jitterMax] based on accountID. Result is never below grokTokenRefreshSkewMin +// when the base window is at least that large. +func grokTokenRefreshWindowWithJitter(accountID int64, refreshWindow time.Duration) time.Duration { + if accountID <= 0 || refreshWindow <= grokTokenRefreshSkewMin { + return refreshWindow + } + h := fnv.New32a() + var b [8]byte + id := uint64(accountID) + for i := 0; i < 8; i++ { + b[i] = byte(id >> (8 * i)) + } + _, _ = h.Write(b[:]) + // Jitter in [0, grokTokenRefreshJitterMax). + jitter := time.Duration(h.Sum32()%uint32(grokTokenRefreshJitterMax/time.Second)) * time.Second + out := refreshWindow - jitter + if out < grokTokenRefreshSkewMin { + return grokTokenRefreshSkewMin + } + return out +} + func (r *GrokTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) { if r == nil || r.grokOAuthService == nil { return nil, errors.New("grok oauth service is not configured") diff --git a/backend/internal/service/grok_token_refresher_test.go b/backend/internal/service/grok_token_refresher_test.go new file mode 100644 index 0000000000..c689985628 --- /dev/null +++ b/backend/internal/service/grok_token_refresher_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGrokTokenRefreshWindowWithJitter_StableAndBounded(t *testing.T) { + base := grokTokenRefreshSkew + w1 := grokTokenRefreshWindowWithJitter(42, base) + w2 := grokTokenRefreshWindowWithJitter(42, base) + require.Equal(t, w1, w2, "same account id must yield stable window") + require.GreaterOrEqual(t, w1, grokTokenRefreshSkewMin) + require.LessOrEqual(t, w1, base) + + // Different accounts should usually differ (not a hard guarantee for all pairs, + // but for sequential ids the hash spread is good enough to assert inequality + // across a small sample). + seen := map[time.Duration]bool{} + for id := int64(1); id <= 50; id++ { + seen[grokTokenRefreshWindowWithJitter(id, base)] = true + } + require.Greater(t, len(seen), 1, "jitter should spread windows across accounts") +} + +func TestGrokTokenRefresher_NeedsRefresh_UsesSkewFloor(t *testing.T) { + refresher := NewGrokTokenRefresher(nil) + // Expires in 50 minutes — within 1h skew, should need refresh. + expires := time.Now().Add(50 * time.Minute).UTC().Format(time.RFC3339) + account := &Account{ + ID: 7, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "at", + "refresh_token": "rt", + "expires_at": expires, + }, + } + // Pass a tiny window; NeedsRefresh raises it to grokTokenRefreshSkew then jitter. + require.True(t, refresher.NeedsRefresh(account, time.Minute)) + + // Far future — no refresh. + account.Credentials["expires_at"] = time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339) + require.False(t, refresher.NeedsRefresh(account, time.Minute)) +} diff --git a/backend/internal/service/grok_upstream_errors.go b/backend/internal/service/grok_upstream_errors.go index 880cd702b0..0039ea4cdc 100644 --- a/backend/internal/service/grok_upstream_errors.go +++ b/backend/internal/service/grok_upstream_errors.go @@ -191,10 +191,17 @@ func grokContentPolicyClientMessage(responseBody []byte) string { // shouldFailoverGrokUpstreamError is the body-aware counterpart of the // status-only failover helper. Grok content refusals must stay on the current // account and be returned to the caller instead of consuming the account pool. +// Free-usage / empty-output / billing bodies also failover even when the HTTP +// status alone would not (e.g. 400 with free-usage-exhausted). func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, responseBody []byte) bool { if isGrokContentPolicyRejection(statusCode, responseBody) { return false } + decision := classifyGrokUpstreamFailure(statusCode, responseBody, "") + switch decision.Class { + case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity: + return decision.ShouldFailover + } return s.shouldFailoverUpstreamError(statusCode) } diff --git a/backend/internal/service/grok_upstream_errors_test.go b/backend/internal/service/grok_upstream_errors_test.go index f8ce5c850c..0dee756b72 100644 --- a/backend/internal/service/grok_upstream_errors_test.go +++ b/backend/internal/service/grok_upstream_errors_test.go @@ -122,6 +122,43 @@ func TestGrokContentPolicy403DoesNotMutateOrFailover(t *testing.T) { require.Zero(t, repo.tempUnschedCalls) } +func TestGrokNonFailoverDoesNotApplyGenericTempUnschedulablePolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{ + accountRepo: repo, + rateLimitService: NewRateLimitService(repo, nil, nil, nil, nil), + } + account := &Account{ + ID: 5099, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{map[string]any{ + "error_code": float64(http.StatusForbidden), + "keywords": []any{"text is sensitive"}, + "duration_minutes": float64(1), + }}, + }, + } + body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + resp := &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}} + + got := svc.failoverOpenAIUpstreamHTTPError( + context.Background(), c, account, resp, body, "text is sensitive", "", + ) + + require.Nil(t, got) + require.Zero(t, repo.tempUnschedCalls) + require.Zero(t, repo.rateLimitedCalls) + require.Zero(t, repo.updateCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestGrokContentPolicy403SharedErrorFallbackDoesNotMutate(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"error":{"code":"content_filter","message":"prohibited content"}}`) @@ -259,6 +296,74 @@ func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *tes require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute)) } +func TestHandleGrokAccountUpstreamErrorDefaultCooldownsRespectPoolMode(t *testing.T) { + for _, statusCode := range []int{ + http.StatusUnauthorized, + http.StatusPaymentRequired, + http.StatusForbidden, + http.StatusInternalServerError, + } { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ + ID: int64(4800 + statusCode), + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + }, + } + body := []byte(`{"error":{"message":"grok access or entitlement denied"}}`) + + svc.handleGrokAccountUpstreamError( + context.Background(), account, statusCode, nil, body, + ) + + require.Zero(t, repo.tempUnschedCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Nil(t, account.TempUnschedulableUntil) + require.Empty(t, account.TempUnschedulableReason) + require.True(t, svc.shouldFailoverGrokUpstreamError(statusCode, body)) + }) + } + + account := &Account{Type: AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true}} + require.True(t, account.IsPoolModeRetryableStatus(http.StatusForbidden)) + + t.Run("explicit temporary rule still applies", func(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ + ID: 4723, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(http.StatusForbidden), + "keywords": []any{"entitlement denied"}, + "duration_minutes": float64(7), + }, + }, + }, + } + before := time.Now() + + svc.handleGrokAccountUpstreamError( + context.Background(), account, http.StatusForbidden, nil, + []byte(`{"error":{"message":"grok access or entitlement denied"}}`), + ) + + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok configured forbidden rule", repo.lastTempUnschedReason) + require.WithinDuration(t, before.Add(7*time.Minute), repo.lastTempUnschedUntil, time.Second) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + }) +} + func TestHandleGrokAccountUpstreamError403UsesConfiguredRule(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 new file mode 100644 index 0000000000..1348d068c5 --- /dev/null +++ b/backend/internal/service/grok_upstream_failure.go @@ -0,0 +1,506 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +// Grok upstream failure classes used to decide temp-unschedulable cooldowns and +// pre-commit account failover. Classification is body-first so free-usage and +// empty-output wording still win when the proxy rewrites status codes. +type GrokUpstreamFailureClass string + +const ( + GrokFailureNone GrokUpstreamFailureClass = "" + GrokFailureFreeUsage GrokUpstreamFailureClass = "subscription:free-usage-exhausted" + GrokFailureBilling GrokUpstreamFailureClass = "billing_quota" + GrokFailureEmptyUpstream GrokUpstreamFailureClass = "empty_upstream" + GrokFailureModelCapacity GrokUpstreamFailureClass = "model_capacity" + GrokFailureRateLimit GrokUpstreamFailureClass = "rate_limit" + GrokFailureAuth GrokUpstreamFailureClass = "auth_error" + GrokFailureServer GrokUpstreamFailureClass = "server_error" +) + +// GrokUpstreamFailureDecision is a pure classification result. Callers map it +// onto existing account state helpers (tempUnscheduleGrok / rateLimitGrok). +// BlockModel is retained for observability; the current scheduler does not +// implement per-model soft-blocks, so free-usage deliberately never sets it. +type GrokUpstreamFailureDecision struct { + Class GrokUpstreamFailureClass + Model string + Cooldown time.Duration + ShouldCooldown bool + // ShouldFailover recommends trying another account before writing a + // terminal response (pre-commit only). Content-policy rejections are + // handled separately and never reach this classifier for failover. + ShouldFailover bool + // BlockModel is true only for empty-output when a model id is known. + // Free-usage never sets this: the account cools, not a single model. + BlockModel bool + Reason string + TokensActual *int64 + TokensLimit *int64 +} + +var ( + reGrokTokenPair = regexp.MustCompile(`(?i)tokens?\s*(?:\(actual\s*/\s*limit\))?\s*[:=]?\s*(\d+)\s*/\s*(\d+)`) + reGrokModelFor = regexp.MustCompile(`(?i)(?:for\s+model|model|模型)\s*[::]?\s*([a-z0-9][a-z0-9._-]{2,80})`) +) + +// classifyGrokUpstreamFailure decides cooldown/failover from status + body. +// Priority (body/code first, status second): +// 1. free-usage exhausted → account cool, no model block, failover +// 2. billing hard quota → longer cool, failover +// 3. empty model output → short cool + optional model soft-block marker, failover +// 4. model capacity → short cool, failover +// 5. bare rate-limit / 429 without free-usage language → cool, failover +// 6. bare 5xx → brief cool, failover +// 7. validation / client errors without quota language → no cool +// +// Content-policy 403s must be filtered by the caller before invoking this. +func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedModel string) GrokUpstreamFailureDecision { + text, code, low := grokUpstreamErrorCorpus(statusCode, responseBody) + model := extractGrokFailureModel(text, responseBody, requestedModel) + actual, limit, hasTokens := parseGrokTokenPair(text) + if !hasTokens { + actual, limit, hasTokens = parseGrokTokenPair(string(responseBody)) + } + + // --- Free usage / rolling quota exhausted --- + if isGrokFreeUsageExhaustedText(low) || isGrokFreeUsageCode(code) || isGrokFreeUsageCode(text) { + d := GrokUpstreamFailureDecision{ + Class: GrokFailureFreeUsage, + Model: model, + Cooldown: grokFreeUsageCooldownDuration(low), + ShouldCooldown: true, + ShouldFailover: true, + BlockModel: false, + Reason: firstNonEmpty(text, code, "free usage exhausted"), + } + if hasTokens { + a, b := actual, limit + d.TokensActual = &a + d.TokensLimit = &b + } + return d + } + + // Billing / hard quota (not free-tier rolling). + // Cooldown stays at 30m to match the existing Grok 402/spending-limit + // handler (longer cools would change ops behavior without a settings knob). + if isGrokBillingQuotaText(low) || statusCode == http.StatusPaymentRequired { + reason := firstNonEmpty(text, "billing quota") + if statusCode == http.StatusPaymentRequired && text == "" { + reason = "payment required" + } + return GrokUpstreamFailureDecision{ + Class: GrokFailureBilling, + Model: model, + Cooldown: 30 * time.Minute, + ShouldCooldown: true, + ShouldFailover: true, + BlockModel: model != "", + Reason: reason, + } + } + + // Empty HTTP 200 / empty model output (often rewritten to synthetic 502). + if isGrokEmptyModelOutputText(low) || isGrokEmptyModelOutputCode(code) { + return GrokUpstreamFailureDecision{ + Class: GrokFailureEmptyUpstream, + Model: model, + Cooldown: 4 * time.Minute, + ShouldCooldown: true, + ShouldFailover: true, + BlockModel: model != "", + Reason: firstNonEmpty(text, "empty model output"), + } + } + + // Model capacity / overloaded. + if isGrokModelCapacityText(low) { + return GrokUpstreamFailureDecision{ + Class: GrokFailureModelCapacity, + Model: model, + Cooldown: 3 * time.Minute, + ShouldCooldown: true, + ShouldFailover: true, + BlockModel: false, + Reason: firstNonEmpty(text, "model capacity"), + } + } + + // Rate limit without free-usage language. + if statusCode == http.StatusTooManyRequests || isGrokRateLimitText(low) { + return GrokUpstreamFailureDecision{ + Class: GrokFailureRateLimit, + Model: model, + Cooldown: 10 * time.Minute, + ShouldCooldown: true, + ShouldFailover: true, + BlockModel: false, + Reason: firstNonEmpty(text, "rate limit"), + } + } + + // Upstream 5xx — brief cool. Empty-output synthetic 502 already handled above. + if statusCode >= 500 && statusCode <= 599 { + return GrokUpstreamFailureDecision{ + Class: GrokFailureServer, + Cooldown: 2 * time.Minute, + ShouldCooldown: true, + ShouldFailover: true, + Reason: firstNonEmpty(text, "server error"), + } + } + + return GrokUpstreamFailureDecision{Reason: text} +} + +func grokUpstreamErrorCorpus(statusCode int, responseBody []byte) (text, code, low string) { + _ = statusCode // classifier already has the transport status; corpus is body-only + raw := strings.TrimSpace(string(responseBody)) + // Strip "upstream status NNN: ..." prefixes so free-usage / quota language is visible. + if _, unwrappedBody, ok := unwrapGrokUpstreamErrorText(raw); ok { + raw = unwrappedBody + } + text = raw + codeFromJSON, msgFromJSON := parseGrokUpstreamErrorJSON(raw) + if msgFromJSON != "" { + if text == "" || len(msgFromJSON) > len(text)/2 || looksLikeGrokQuotaMessage(msgFromJSON) { + text = msgFromJSON + } + } + // Prefer structured fields from the original body when present. + if len(responseBody) > 0 { + if m := strings.TrimSpace(firstNonEmpty( + gjson.GetBytes(responseBody, "error.message").String(), + gjson.GetBytes(responseBody, "message").String(), + gjson.GetBytes(responseBody, "error").String(), + )); m != "" && (text == "" || looksLikeGrokQuotaMessage(m)) { + text = m + } + if c := strings.TrimSpace(firstNonEmpty( + gjson.GetBytes(responseBody, "error.code").String(), + gjson.GetBytes(responseBody, "code").String(), + )); c != "" { + codeFromJSON = c + } + } + code = codeFromJSON + low = strings.ToLower(strings.TrimSpace(text)) + if code != "" && !strings.Contains(low, strings.ToLower(code)) { + low = strings.ToLower(code) + " " + low + } + return text, code, low +} + +func unwrapGrokUpstreamErrorText(errText string) (status int, body string, ok bool) { + text := strings.TrimSpace(errText) + if text == "" { + return 0, "", false + } + lower := strings.ToLower(text) + for _, p := range []string{"upstream status ", "status "} { + if !strings.HasPrefix(lower, p) { + continue + } + rest := strings.TrimSpace(text[len(p):]) + i := 0 + for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' { + status = status*10 + int(rest[i]-'0') + i++ + } + if status <= 0 || i == 0 { + return 0, "", false + } + rest = strings.TrimSpace(rest[i:]) + if strings.HasPrefix(rest, ":") { + rest = strings.TrimSpace(rest[1:]) + } + return status, rest, true + } + return 0, "", false +} + +func parseGrokUpstreamErrorJSON(errText string) (code, message string) { + text := strings.TrimSpace(errText) + if text == "" || text[0] != '{' { + return "", "" + } + var payload map[string]any + if json.Unmarshal([]byte(text), &payload) != nil { + return "", "" + } + if v, ok := payload["code"].(string); ok { + code = v + } + if v, ok := payload["message"].(string); ok { + message = v + } + if errObj, ok := payload["error"].(map[string]any); ok { + if v, ok := errObj["code"].(string); ok && code == "" { + code = v + } + if v, ok := errObj["message"].(string); ok && message == "" { + message = v + } + } + if errStr, ok := payload["error"].(string); ok && message == "" { + message = errStr + } + return strings.TrimSpace(code), strings.TrimSpace(message) +} + +func looksLikeGrokQuotaMessage(s string) bool { + low := strings.ToLower(s) + return strings.Contains(low, "quota") || + strings.Contains(low, "usage") || + strings.Contains(low, "credit") || + strings.Contains(low, "额度") || + strings.Contains(low, "free") +} + +func isGrokFreeUsageCode(code string) bool { + c := strings.ToLower(strings.TrimSpace(code)) + if c == "" { + return false + } + if strings.Contains(c, "subscription:free-usage-exhausted") || + strings.Contains(c, "free-usage-exhausted") || + strings.Contains(c, "free_usage_exhausted") || + strings.Contains(c, "usage-limit-exceeded") || + strings.Contains(c, "usage_limit_exceeded") { + return true + } + return (strings.Contains(c, "free-usage") || strings.Contains(c, "free_usage")) && + (strings.Contains(c, "exhaust") || strings.Contains(c, "exceed") || strings.Contains(c, "limit")) +} + +func isGrokFreeUsageExhaustedText(low string) bool { + if low == "" { + return false + } + if strings.Contains(low, "free-usage-exhausted") || + strings.Contains(low, "free_usage_exhausted") || + strings.Contains(low, "subscription:free-usage") || + strings.Contains(low, "usage-limit-exceeded") || + strings.Contains(low, "usage_limit_exceeded") || + strings.Contains(low, "free-tier-limit") || + strings.Contains(low, "free_tier_limit") { + return true + } + if strings.Contains(low, "free usage") || + strings.Contains(low, "included free usage") || + strings.Contains(low, "used all the included free") || + strings.Contains(low, "you've used all the included free") || + strings.Contains(low, "you have used all the included free") || + strings.Contains(low, "free quota") || + strings.Contains(low, "no remaining free") || + strings.Contains(low, "out of free") || + strings.Contains(low, "usage resets over a rolling") || + (strings.Contains(low, "free tier") && (strings.Contains(low, "exhaust") || strings.Contains(low, "limit") || strings.Contains(low, "exceed"))) { + return true + } + for _, p := range []string{ + "额度耗尽", "额度用完", "额度不足", "额度已用尽", "额度已耗尽", + "免费额度", "免费用量", "用量用完", "用量耗尽", "用量超限", "用量已用尽", + "配额耗尽", "配额已用尽", "配额不足", "配额超限", "配额用完", + "没有额度", "没额度", "无额度", "可用额度不足", "模型额度", + "临时额度", "额度已满", "额度超限", "额度达到上限", + "模型额度用完", "模型额度耗尽", "账号额度用完", "账号额度耗尽", + "额度不够", "没额度了", "额度没了", "用完额度", "耗尽额度", + } { + if strings.Contains(low, p) { + return true + } + } + if (strings.Contains(low, "quota") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed") || strings.Contains(low, "limit"))) || + (strings.Contains(low, "usage") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed")) && (strings.Contains(low, "limit") || strings.Contains(low, "free") || strings.Contains(low, "model"))) { + if strings.Contains(low, "free") || strings.Contains(low, "rolling") || + strings.Contains(low, "24-hour") || strings.Contains(low, "24 hour") || + strings.Contains(low, "model") || strings.Contains(low, "subscription") || + strings.Contains(low, "included") || strings.Contains(low, "tokens") { + return true + } + } + if a, b, ok := parseGrokTokenPair(low); ok && b > 0 && a >= b { + if strings.Contains(low, "free") || strings.Contains(low, "subscription") || + strings.Contains(low, "included") || strings.Contains(low, "model") || + strings.Contains(low, "usage") || strings.Contains(low, "quota") || + strings.Contains(low, "rolling") { + return true + } + } + return false +} + +func isGrokBillingQuotaText(low string) bool { + if low == "" { + return false + } + if strings.Contains(low, "insufficient_quota") { + return true + } + if strings.Contains(low, "billing") && strings.Contains(low, "quota") { + return true + } + 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") { + return true + } + if strings.Contains(low, "余额不足") || strings.Contains(low, "欠费") || strings.Contains(low, "需要付费") { + return true + } + return false +} + +func isGrokModelCapacityText(low string) bool { + return strings.Contains(low, "capacity") || + strings.Contains(low, "overloaded") || + strings.Contains(low, "server_busy") || + strings.Contains(low, "too many concurrent") || + strings.Contains(low, "engine_overloaded") +} + +func isGrokRateLimitText(low string) bool { + return strings.Contains(low, "rate limit") || + strings.Contains(low, "rate_limit") || + strings.Contains(low, "too many requests") || + strings.Contains(low, "请求过于频繁") || + strings.Contains(low, "速率限制") +} + +func isGrokEmptyModelOutputText(low string) bool { + if low == "" { + return false + } + return strings.Contains(low, "empty model output") || + strings.Contains(low, "no content/tool_calls") || + strings.Contains(low, "no client-visible content") || + strings.Contains(low, "empty_upstream") || + strings.Contains(low, "empty upstream") +} + +func isGrokEmptyModelOutputCode(code string) bool { + c := strings.ToLower(strings.TrimSpace(code)) + if c == "" { + return false + } + return c == "empty_upstream" || + c == "empty-model-output" || + c == "empty_model_output" || + strings.Contains(c, "empty_upstream") || + strings.Contains(c, "empty-model-output") +} + +func grokFreeUsageCooldownDuration(low string) time.Duration { + // "rolling 24-hour" describes the upstream usage window, not a cooldown + // that starts when this proxy observes a 429. Without an upstream reset + // timestamp we cannot know when the oldest usage exits that window, so use + // a short probe interval and let a successful probe clear the block. + return grokFreeUsageProbeCooldown +} + +const grokFreeUsageProbeCooldown = 10 * time.Minute + +func parseGrokTokenPair(errText string) (actual, limit int64, ok bool) { + m := reGrokTokenPair.FindStringSubmatch(errText) + if len(m) != 3 { + return 0, 0, false + } + a, errA := strconv.ParseInt(m[1], 10, 64) + b, errB := strconv.ParseInt(m[2], 10, 64) + if errA != nil || errB != nil { + return 0, 0, false + } + return a, b, true +} + +func extractGrokFailureModel(text string, responseBody []byte, fallback string) string { + if m := reGrokModelFor.FindStringSubmatch(text); len(m) == 2 { + return normalizeGrokFailureModelID(m[1]) + } + if len(responseBody) > 0 { + if m := strings.TrimSpace(firstNonEmpty( + gjson.GetBytes(responseBody, "error.model").String(), + gjson.GetBytes(responseBody, "model").String(), + )); m != "" { + return normalizeGrokFailureModelID(m) + } + } + return normalizeGrokFailureModelID(fallback) +} + +// normalizeGrokFailureModelID trims whitespace and trailing punctuation the +// "for model X." extractors sometimes capture. +func normalizeGrokFailureModelID(model string) string { + model = strings.TrimSpace(model) + model = strings.TrimRight(model, ".,;:!?") + return strings.TrimSpace(model) +} + +// applyGrokUpstreamFailureDecision maps a classification onto existing account +// health helpers. Returns true when the decision fully handled the error path +// (caller should not apply the status-code switch defaults again). +func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision( + ctx context.Context, + account *Account, + decision GrokUpstreamFailureDecision, +) bool { + if s == nil || account == nil || !decision.ShouldCooldown || decision.Cooldown <= 0 { + return false + } + // Keep reasons short and stable for ops UI / temp_unschedulable_reason. + var reason string + switch decision.Class { + case GrokFailureFreeUsage: + reason = "grok free usage exhausted" + // Model-scoped free usage: soft-block only the named model so other + // models on the same account remain pickable (grok2api ModelQuotaBlock). + low := strings.ToLower(decision.Reason) + if decision.Model != "" && isGrokModelSpecificFreeUsage(low, decision.Model) { + until := time.Now().Add(decision.Cooldown) + markGrokModelQuotaBlock(account.ID, decision.Model, until) + // The upstream explicitly scoped exhaustion to this model, so an + // account-wide cool would incorrectly take healthy sibling models out + // of rotation. + return true + } + case GrokFailureBilling: + low := strings.ToLower(decision.Reason) + if strings.Contains(low, "spending") || strings.Contains(low, "credits") { + // Spending-limit/credit exhaustion is a billing-window condition. Keep + // the account recoverable and let the normal rate-limit recovery clear it. + s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now())) + return true + } + // Keep the historical 402/payment reason for ops UI + regression tests. + reason = "grok payment required" + case GrokFailureEmptyUpstream: + reason = "grok empty model output" + case GrokFailureModelCapacity: + reason = "grok model capacity" + case GrokFailureRateLimit: + // Pure 429 without free-usage language keeps the existing rate-limit + // snapshot path (Retry-After / quota headers). Body-only rate-limit + // phrasing still cools here via ShouldCooldown from the classifier, but + // the handler only invokes this for non-RateLimit classes. + return false + case GrokFailureServer: + reason = "grok upstream temporary error" + default: + return false + } + s.tempUnscheduleGrok(ctx, account, decision.Cooldown, reason) + return true +} diff --git a/backend/internal/service/grok_upstream_failure_test.go b/backend/internal/service/grok_upstream_failure_test.go new file mode 100644 index 0000000000..6f0a916a78 --- /dev/null +++ b/backend/internal/service/grok_upstream_failure_test.go @@ -0,0 +1,181 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClassifyGrokUpstreamFailure_FreeUsage(t *testing.T) { + cases := []struct { + name string + status int + body string + }{ + { + name: "code free-usage-exhausted", + status: http.StatusTooManyRequests, + body: `{"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage for model grok-4.5. Usage resets over a rolling 24-hour window."}}`, + }, + { + name: "chinese body without 429", + status: http.StatusBadRequest, + body: `{"error":{"message":"模型额度用完,请稍后再试"}}`, + }, + { + name: "token pair with free marker", + status: http.StatusOK, + body: `{"error":{"message":"free usage tokens (actual / limit): 2000000 / 2000000 for model grok-4.5"}}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := classifyGrokUpstreamFailure(tc.status, []byte(tc.body), "grok-4.5") + require.Equal(t, GrokFailureFreeUsage, d.Class) + require.True(t, d.ShouldCooldown) + require.True(t, d.ShouldFailover) + require.False(t, d.BlockModel, "free-usage must not soft-block models") + require.Equal(t, grokFreeUsageProbeCooldown, d.Cooldown) + }) + } +} + +func TestClassifyGrokUpstreamFailure_EmptyUpstream(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusBadGateway, []byte(`empty model output: no content/tool_calls`), "grok-4.5") + require.Equal(t, GrokFailureEmptyUpstream, d.Class) + require.True(t, d.ShouldCooldown) + require.True(t, d.ShouldFailover) + require.True(t, d.BlockModel) + require.Equal(t, 4*time.Minute, d.Cooldown) +} + +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) + require.True(t, d.ShouldCooldown) + require.True(t, d.ShouldFailover) +} + +func TestClassifyGrokUpstreamFailure_ValidationNoCool(t *testing.T) { + d := classifyGrokUpstreamFailure(http.StatusBadRequest, []byte(`{"error":{"message":"invalid tool schema"}}`), "") + require.Equal(t, GrokFailureNone, d.Class) + require.False(t, d.ShouldCooldown) + require.False(t, d.ShouldFailover) +} + +func TestClassifyGrokUpstreamFailure_FreeUsageWinsOver5xx(t *testing.T) { + // Proxy may rewrite free-usage into synthetic 502; body must win. + d := classifyGrokUpstreamFailure(http.StatusBadGateway, []byte(`subscription:free-usage-exhausted for model grok-4.3`), "grok-4.3") + require.Equal(t, GrokFailureFreeUsage, d.Class) + require.NotEqual(t, GrokFailureServer, d.Class) +} + +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_ContentPolicyStillNoFailover(t *testing.T) { + svc := &OpenAIGatewayService{} + body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`) + require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body)) +} + +func TestHandleGrokAccountUpstreamError_FreeUsageBodyCoolsAccount(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9101, Platform: PlatformGrok, Type: AccountTypeOAuth} + before := time.Now() + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage. Usage resets over a rolling 24-hour window."}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body) + + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok free usage exhausted", repo.lastTempUnschedReason) + // Rolling-window exhaustion must use a short probe cooldown when no + // upstream absolute reset is available; it must not start a 24h lock here. + require.Greater(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown-time.Second)) + require.Less(t, repo.lastTempUnschedUntil, before.Add(grokFreeUsageProbeCooldown+time.Second)) +} + +func TestHandleGrokAccountUpstreamError_FreeUsageUsesUpstreamReset(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9102, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted; rolling 24-hour window"}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, + http.Header{"Retry-After": []string{"3600"}}, body) + + require.Zero(t, repo.tempUnschedCalls) + require.WithinDuration(t, time.Now().Add(time.Hour), repo.lastRateLimitResetAt, 2*time.Second) +} + +func TestHandleGrokAccountUpstreamError_EmptyOutputCoolsAccount(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9102, Platform: PlatformGrok, Type: AccountTypeOAuth} + before := time.Now() + + svc.handleGrokAccountUpstreamError( + context.Background(), account, http.StatusBadGateway, nil, + []byte(`empty model output: no content/tool_calls`), + ) + + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok empty model output", repo.lastTempUnschedReason) + require.WithinDuration(t, before.Add(4*time.Minute), repo.lastTempUnschedUntil, time.Second) +} + +func TestHandleGrokAccountUpstreamError_FreeUsageDoesNotCoolPoolMode(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ + ID: 9103, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + }, + } + body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"free usage exhausted"}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body) + + require.Zero(t, repo.tempUnschedCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestHandleGrokAccountUpstreamError_ContentPolicyStillNoMutation(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9104, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) + + require.Zero(t, repo.tempUnschedCalls) +} + +func TestHandleGrokAccountUpstreamError_Entitlement403Unchanged(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 9105, Platform: PlatformGrok, Type: AccountTypeOAuth} + before := time.Now() + + svc.handleGrokAccountUpstreamError( + context.Background(), account, http.StatusForbidden, nil, + []byte(`{"error":{"message":"subscription required"}}`), + ) + + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, "grok access or entitlement denied", repo.lastTempUnschedReason) + require.Greater(t, repo.lastTempUnschedUntil, before.Add(29*time.Minute)) + require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute)) +} diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go new file mode 100644 index 0000000000..cdfb54bcbf --- /dev/null +++ b/backend/internal/service/grok_upstream_headers.go @@ -0,0 +1,74 @@ +package service + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "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 + grokClientIdentifierHeader = xai.CLIClientIdentifier + grokClientModeHeader = xai.CLIClientMode +) + +// defaultGrokUpstreamUserAgent is the pinned Grok CLI / workspace UA. +// Grok upstream must not forward Claude Code / Codex / browser client UAs. +func defaultGrokUpstreamUserAgent() string { + return xai.CLIUserAgent(xai.ResolveCLIVersion()) +} + +func applyDefaultGrokUpstreamHeaders(req *http.Request) { + if req == nil { + return + } + // Always stamp CLI identity. Do not preserve inbound client UA (Claude Code, + // Codex, curl, etc.) — xAI chat/CLI surfaces fingerprint the client string. + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) + req.Header.Set("x-grok-client-version", xai.ResolveCLIVersion()) + req.Header.Set("x-grok-client-identifier", grokClientIdentifierHeader) +} + +func applyGrokTLSProfileHeaders(req *http.Request, profile *tlsfingerprint.Profile) { + // HEAD Profile is TLS-only (no HTTP UserAgent/Originator fields). Always stamp CLI identity. + applyDefaultGrokUpstreamHeaders(req) + _ = profile +} + +// openAITLSFingerprintRuntime is the resolved TLS fingerprint routing result +// used by OpenAI/Grok outbound header application. Defined here so Grok header +// helpers compile even when the full OpenAI TLS router is not present on HEAD. +type openAITLSFingerprintRuntime struct { + Profile *tlsfingerprint.Profile + UpstreamUserAgent string + UpstreamOriginator string + Matched bool +} + +func applyGrokRuntimeHeaders(req *http.Request, runtime openAITLSFingerprintRuntime) { + applyDefaultGrokUpstreamHeaders(req) + if req == nil { + return + } + // Apply Originator only; force CLI UA after so router overrides cannot + // leak Codex/Claude Code identity onto Grok upstream. + if originator := strings.TrimSpace(runtime.UpstreamOriginator); originator != "" { + req.Header.Set("Originator", originator) + } + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) +} + +// resolveGrokUpstreamUserAgent always returns the pinned Grok CLI User-Agent. +// Inbound client UAs (Claude Code, Codex, browsers, libraries) are never forwarded. +func resolveGrokUpstreamUserAgent(_ *gin.Context) string { + return defaultGrokUpstreamUserAgent() +} diff --git a/backend/internal/service/grok_upstream_headers_test.go b/backend/internal/service/grok_upstream_headers_test.go new file mode 100644 index 0000000000..18972f4801 --- /dev/null +++ b/backend/internal/service/grok_upstream_headers_test.go @@ -0,0 +1,86 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" +) + +func TestApplyDefaultGrokUpstreamHeadersUsesCLIUserAgent(t *testing.T) { + t.Setenv(xai.CLIVersionEnv, "") + + req, err := http.NewRequest(http.MethodGet, "https://api.x.ai/v1/responses", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "claude-code/1.2.3") + req.Header.Set("x-grok-client-version", "none") + + applyDefaultGrokUpstreamHeaders(req) + + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientIdentifier, req.Header.Get("x-grok-client-identifier")) +} + +func TestApplyDefaultGrokUpstreamHeadersHonorsCLIVersionOverride(t *testing.T) { + t.Setenv(xai.CLIVersionEnv, "0.2.95") + + req, err := http.NewRequest(http.MethodGet, "https://api.x.ai/v1/responses", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "codex_cli_rs/0.144.0") + + applyDefaultGrokUpstreamHeaders(req) + + require.Equal(t, "0.2.95", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("0.2.95"), req.Header.Get("User-Agent")) + require.Equal(t, "grok-shell", req.Header.Get("x-grok-client-identifier")) +} + +func TestResolveGrokUpstreamUserAgentNeverPassthrough(t *testing.T) { + t.Setenv(xai.CLIVersionEnv, "") + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "claude-cli/2.0.0 (Mac OS; arm64)") + + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), resolveGrokUpstreamUserAgent(c)) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), resolveGrokUpstreamUserAgent(nil)) +} + +func TestApplyGrokRuntimeHeadersKeepsCLIUserAgent(t *testing.T) { + t.Setenv(xai.CLIVersionEnv, "") + + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "claude-code/9.9.9") + + applyGrokRuntimeHeaders(req, openAITLSFingerprintRuntime{ + UpstreamUserAgent: "codex_cli_rs/0.144.0", + UpstreamOriginator: "codex_cli_rs", + }) + + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) + require.Equal(t, "codex_cli_rs", req.Header.Get("Originator")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) +} + +func TestApplyGrokTLSProfileHeadersAlwaysUsesCLIUserAgent(t *testing.T) { + t.Setenv(xai.CLIVersionEnv, "") + + req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "grok-native/1.0") + + // HEAD Profile is TLS-only; Originator/UserAgent HTTP fields are not present. + applyGrokTLSProfileHeaders(req, &tlsfingerprint.Profile{Name: "chrome"}) + + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) +} diff --git a/backend/internal/service/grok_upstream_url.go b/backend/internal/service/grok_upstream_url.go index 362e746e6f..9574b3adbe 100644 --- a/backend/internal/service/grok_upstream_url.go +++ b/backend/internal/service/grok_upstream_url.go @@ -1,8 +1,11 @@ package service import ( + "context" "errors" "fmt" + "net/url" + "strings" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" @@ -67,20 +70,28 @@ func redactedGrokBaseURLValidator(validator xai.BaseURLValidator) xai.BaseURLVal } } -func buildGrokResponsesURL(account *Account, cfg *config.Config) (string, error) { +func buildGrokResponsesURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) { validator, err := grokBaseURLValidator(account, cfg) if err != nil { return "", err } - return xai.BuildResponsesURLWithValidator(account.GetGrokBaseURL(), validator) + baseURL := account.GetGrokBaseURL() + if len(settings) > 0 && settings[0] != nil { + baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account) + } + return xai.BuildResponsesURLWithValidator(baseURL, validator) } -func buildGrokChatCompletionsURL(account *Account, cfg *config.Config) (string, error) { +func buildGrokChatCompletionsURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) { validator, err := grokBaseURLValidator(account, cfg) if err != nil { return "", err } - return xai.BuildChatCompletionsURLWithValidator(account.GetGrokBaseURL(), validator) + baseURL := account.GetGrokBaseURL() + if len(settings) > 0 && settings[0] != nil { + baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account) + } + return xai.BuildChatCompletionsURLWithValidator(baseURL, validator) } // buildGrokBillingURL 解析 billing 探测端点:跟随账号的转发 base_url, @@ -90,7 +101,14 @@ func buildGrokBillingURL(account *Account, cfg *config.Config, weekly bool) (str if err != nil { return "", err } - return xai.BuildBillingURLWithValidator(account.GetGrokBaseURL(), weekly, validator) + baseURL := account.GetGrokBaseURL() + // Official public/regional API hosts do not expose Grok Build billing. + // Keep custom relays on their configured host because they may proxy the CLI + // billing path alongside inference. + if xai.IsOfficialBaseURL(baseURL) && !isGrokCLIProxyBaseURL(baseURL) { + baseURL = xai.DefaultCLIBaseURL + } + return xai.BuildBillingURLWithValidator(baseURL, weekly, validator) } func buildGrokMediaURL(account *Account, cfg *config.Config, endpoint GrokMediaEndpoint, requestID string) (string, error) { @@ -122,3 +140,42 @@ func buildGrokMediaURL(account *Account, cfg *config.Config, endpoint GrokMediaE return "", fmt.Errorf("unsupported grok media endpoint: %s", endpoint) } } + +// buildGrokVoiceURL returns the official xAI Voice API endpoint. +// Voice HTTP (/tts, /stt, /custom-voices) and WS (/realtime) are only exposed +// by api.x.ai — the CLI chat proxy does not implement them. When the account +// base_url points at the CLI proxy (or is empty), fall back to DefaultBaseURL. +func buildGrokVoiceURL(account *Account, cfg *config.Config, endpoint string) (string, error) { + validator, err := grokBaseURLValidator(account, cfg) + if err != nil { + return "", err + } + base := "" + if account != nil { + base = account.GetGrokMediaBaseURL() + } + if strings.TrimSpace(base) == "" || isGrokCLIProxyBaseURL(base) { + base = xai.DefaultBaseURL + } + validated, err := validator(base) + if err != nil { + return "", err + } + ep := strings.Trim(strings.TrimSpace(endpoint), "/") + if ep == "" { + return "", fmt.Errorf("voice endpoint is required") + } + parts := strings.Split(ep, "/") + encoded := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" || part == "." || part == ".." { + return "", fmt.Errorf("invalid voice endpoint path") + } + encoded = append(encoded, url.PathEscape(part)) + } + return strings.TrimRight(validated, "/") + "/" + strings.Join(encoded, "/"), nil +} + +func isGrokCLIProxyBaseURL(raw string) bool { + return isGrokCLIProxyTarget(raw) +} diff --git a/backend/internal/service/grok_upstream_url_test.go b/backend/internal/service/grok_upstream_url_test.go index e2f6e8bce5..8544823b4a 100644 --- a/backend/internal/service/grok_upstream_url_test.go +++ b/backend/internal/service/grok_upstream_url_test.go @@ -255,6 +255,26 @@ func TestGrokOAuthURLPolicy(t *testing.T) { }) } +func TestBuildGrokBillingURLUsesCLIForOfficialAPIHosts(t *testing.T) { + for _, baseURL := range []string{xai.DefaultBaseURL, "https://us-west-2.api.x.ai/v1"} { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"base_url": baseURL}} + + weekly, err := buildGrokBillingURL(account, &config.Config{}, true) + require.NoError(t, err) + require.Equal(t, xai.DefaultCLIBaseURL+xai.BillingWeeklyPath, weekly) + } +} + +func TestBuildGrokBillingURLKeepsCustomRelay(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ + "base_url": "https://relay.example.test/xai/v1", + }} + + monthly, err := buildGrokBillingURL(account, &config.Config{}, false) + require.NoError(t, err) + require.Equal(t, "https://relay.example.test/xai/v1"+xai.BillingMonthlyPath, monthly) +} + func TestGrokBillingURLFollowsAccountBaseURL(t *testing.T) { t.Run("oauth default stays on CLI gateway", func(t *testing.T) { account := &Account{ diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index b71e6b9e84..ddde276e22 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "math" "strings" "time" @@ -54,10 +55,21 @@ type Group struct { VideoPrice480P *float64 VideoPrice720P *float64 VideoPrice1080P *float64 + // VideoModelPrices is optional per-model-family per-second pricing + // (groups.video_model_prices JSONB). Shape: family → resolution → USD/s. + // When set for a model, overrides VideoPrice* for that model only. + VideoModelPrices map[string]map[string]float64 // Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用); // nil 表示使用默认价 defaultWebSearchPricePerCall(官方 $10/1000 次)。 WebSearchPricePerCall *float64 + // 搜索工具显式定价(per 1k calls)。 + SearchPricePer1k *float64 + // Grok Voice 显式定价(分组级,不按文本 RateMultiplier)。 + AudioRealtimePricePerMin *float64 + AudioTTSPricePerMillionChars *float64 + AudioSTTPricePerHour *float64 + // Claude Code 客户端限制 ClaudeCodeOnly bool FallbackGroupID *int64 @@ -99,6 +111,14 @@ type Group struct { // ReasoningEffortMappings rewrites explicit request values before applying the ceiling. ReasoningEffortMappings []ReasoningEffortMapping + // 分组利润控制(五个 token 计费平台可启用)。 + // 调度准入条件:账号倍率 U 满足 U <= D*(1-margin-buffer), + // D 为请求用户当刻有效下游倍率(用户覆盖 ?? 分组默认,再乘高峰因子)。 + // 只过滤候选账号,不改变既有排序/评分/粘性/熔断。 + ProfitControlEnabled bool + ProfitMinMargin float64 // 最低毛利率,小数存储(0.30=30%) + ProfitSafetyBuffer float64 // 安全缓冲,小数,与 margin 相加后从 D 中扣除 + CreatedAt time.Time UpdatedAt time.Time @@ -159,6 +179,30 @@ func (g *Group) GetVideoPrice(resolution string) *float64 { } } +// GetVideoPriceForModel prefers VideoModelPrices for the model family, then flat columns. +func (g *Group) GetVideoPriceForModel(model, resolution string) *float64 { + if g == nil { + return nil + } + if price := LookupVideoModelPrice(g.VideoModelPrices, model, resolution); price != nil { + return price + } + return g.GetVideoPrice(resolution) +} + +// VideoPriceConfig builds billing config including optional per-model map. +func (g *Group) VideoPriceConfig() *VideoPriceConfig { + if g == nil { + return nil + } + return &VideoPriceConfig{ + Price480P: g.VideoPrice480P, + Price720P: g.VideoPrice720P, + Price1080P: g.VideoPrice1080P, + ModelPrices: NormalizeVideoModelPrices(g.VideoModelPrices), + } +} + // IsGroupContextValid reports whether a group from context has the fields required for routing decisions. func IsGroupContextValid(group *Group) bool { if group == nil { @@ -336,3 +380,80 @@ func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (t text = base * peak return } + +// validProfitControlRatio 判定 margin/buffer 是否为可落库的合法小数:[0,1) 且非 NaN/Inf。 +func validProfitControlRatio(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1 +} + +// NormalizeGroupPlatform 把创建分组时省略的 platform 归一化为默认平台。 +// handler 的入参预校验必须与 CreateGroup 落库时用同一个归一化结果,否则 +// 「省略 platform + 启用利润控制」会被 handler 以「平台不支持」400 掉, +// 而该分组本会被建成受支持的 anthropic 分组。 +func NormalizeGroupPlatform(platform string) string { + if platform == "" { + return PlatformAnthropic + } + return platform +} + +// ValidateProfitControlConfig 是分组利润控制配置的唯一校验来源,handler 与 service 层共用。 +// enabled=true 时仅允许五个可计费平台分组;margin/buffer 各自 ∈ [0,1),且 margin+buffer < 1 +// (相加 >=1 时阈值 <=0,所有可核价账号都会被排除,视为配置错误而不是静默全黑)。 +// enabled=false 时放行(不关心平台),由 Normalize 兜底清洗数值。 +func ValidateProfitControlConfig(platform string, enabled bool, minMargin, safetyBuffer float64) error { + if !enabled { + return nil + } + if !profitControlPlatformSupported(platform) { + return errors.New("利润控制仅支持 openai、anthropic、gemini、grok、antigravity 平台分组") + } + if !validProfitControlRatio(minMargin) { + return fmt.Errorf("profit_min_margin 应为 [0,1) 的小数,got %v", minMargin) + } + if !validProfitControlRatio(safetyBuffer) { + return fmt.Errorf("profit_safety_buffer 应为 [0,1) 的小数,got %v", safetyBuffer) + } + if minMargin+safetyBuffer >= 1 { + return errors.New("profit_min_margin 与 profit_safety_buffer 之和必须小于 1,否则将排除全部账号") + } + return nil +} + +// NormalizeProfitControlConfig 归一化最终落库的利润控制配置,CreateGroup 与 UpdateGroup 共用(唯一收口): +// - 非五个平台分组不携带利润控制,一律重置为默认(关、0、0); +// - 支持平台关闭开关时保留合法数值(便于再次启用),清洗 NaN/Inf/越界脏值。 +// +// 与 ValidateProfitControlConfig 的分工同高峰倍率:先归一化、后校验, +// 使"openai 转其他平台"这类更新能静默清空利润配置而不是被校验拒绝。 +func NormalizeProfitControlConfig(platform string, enabled bool, minMargin, safetyBuffer float64) (bool, float64, float64) { + if !profitControlPlatformSupported(platform) { + return false, 0, 0 + } + if !enabled { + if !validProfitControlRatio(minMargin) { + minMargin = 0 + } + if !validProfitControlRatio(safetyBuffer) { + safetyBuffer = 0 + } + } + return enabled, minMargin, safetyBuffer +} + +func profitControlPlatformSupported(platform string) bool { + switch platform { + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity: + return true + default: + return false + } +} + +// GetSearchPricePer1k returns explicit search/tool price per 1k calls if configured. +func (g *Group) GetSearchPricePer1k() *float64 { + if g == nil { + return nil + } + return g.SearchPricePer1k +} diff --git a/backend/internal/service/group_profit_platform_test.go b/backend/internal/service/group_profit_platform_test.go new file mode 100644 index 0000000000..28a5f2290a --- /dev/null +++ b/backend/internal/service/group_profit_platform_test.go @@ -0,0 +1,20 @@ +package service + +import "testing" + +// TestNormalizeGroupPlatformDefaultsToAnthropic 钉死创建分组时省略 platform 的 +// 归一化结果:handler 的利润控制预校验与 CreateGroup 落库共用这个函数, +// 两边一旦漂移,「省略 platform + profit_control_enabled=true」的创建请求 +// 会被 handler 以「平台不支持」400 掉,而该分组本会建成受支持的 anthropic。 +func TestNormalizeGroupPlatformDefaultsToAnthropic(t *testing.T) { + if got := NormalizeGroupPlatform(""); got != PlatformAnthropic { + t.Fatalf("空 platform 应归一化为 %s,got %s", PlatformAnthropic, got) + } + if got := NormalizeGroupPlatform(PlatformOpenAI); got != PlatformOpenAI { + t.Fatalf("显式 platform 不应被改写,got %s", got) + } + // 归一化后的默认平台必须真的支持利润控制,否则预校验仍会误拒。 + if err := ValidateProfitControlConfig(NormalizeGroupPlatform(""), true, 0.3, 0.05); err != nil { + t.Fatalf("省略 platform 且启用利润控制不应被预校验拒绝: %v", err) + } +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 72635c1d68..afa882a9c9 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -23,8 +23,58 @@ import ( var ( // 匹配 User-Agent 版本号: xxx/x.y.z userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) + + // fingerprintUserAgentPattern 校验可写入账号级持久身份的 User-Agent 形态: + // /.. 之后必须紧跟空白或字符串结束。 + // 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。 + fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`) ) +const ( + // claudeCLIUserAgentProduct 是官方 Claude Code CLI 的产品名(小写)。 + claudeCLIUserAgentProduct = "claude-cli" + // maxFingerprintUserAgentLength 限制写入缓存的 User-Agent 长度。 + maxFingerprintUserAgentLength = 256 + // maxClaudeCLIMajorVersionSkew 是 claude-cli 主版本号相对 sub2api 自身伪装 + // 版本(claude.CLICurrentVersion)允许的最大超前量。给足两个大版本的升级 + // 窗口,同时挡掉 999 这类哨兵版本号。 + maxClaudeCLIMajorVersionSkew = 2 +) + +// isAcceptableFingerprintUserAgent 判断 User-Agent 是否可作为账号级持久身份写入缓存。 +// +// 指纹是账号级、“只升不降”、活跃账号懒续期后近乎永不过期的持久状态,且系统内 +// 没有重置入口。一旦写入畸形或哨兵版本(如 claude-cli/999.0.0-local),该账号 +// 此后所有上游请求都会在 HTTP 头与请求体 cc_version 两处声称这个不存在的版本, +// 被上游判定为非正版客户端并持续返回不带限流重置头的 429;无重置头又会落到 5 秒 +// 兜底冷却,账号池收缩后对外表现为 503 风暴。 +// +// 校验必须放在创建与升级两条路径的共同入口:只在 isNewerVersion 处加校验是不够的, +// createFingerprintFromHeaders 首次创建时同样会原样保存畸形 UA,删键恢复后账号可被 +// 同一客户端立即再次毒化。 +func isAcceptableFingerprintUserAgent(ua string) bool { + ua = strings.TrimSpace(ua) + if ua == "" || len(ua) > maxFingerprintUserAgentLength { + return false + } + if !fingerprintUserAgentPattern.MatchString(ua) { + return false + } + // 非 claude-cli 产品不做版本区间约束:形态合法即可,避免误伤其他合法客户端。 + if extractProduct(ua) != claudeCLIUserAgentProduct { + return true + } + major, _, _, ok := parseUserAgentVersion(ua) + if !ok { + return false + } + currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion) + if !currentOK { + return true + } + return major <= currentMajor+maxClaudeCLIMajorVersionSkew +} + // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", @@ -76,20 +126,46 @@ func NewIdentityService(cache IdentityCache) *IdentityService { // 如果缓存存在,检测user-agent版本,新版本则更新 // 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存 func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header) (*Fingerprint, error) { + // 入口统一校验:创建与升级两条路径共用,任一路径漏掉都会让畸形 UA 被持久化。 + clientUA := strings.TrimSpace(headers.Get("User-Agent")) + uaAcceptable := isAcceptableFingerprintUserAgent(clientUA) + // 尝试从缓存获取指纹 cached, err := s.cache.GetFingerprint(ctx, accountID) if err == nil && cached != nil { needWrite := false - // 检查客户端的user-agent是否是更新版本 - clientUA := headers.Get("User-Agent") - if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { + // 只在真正阻止了一次写入时记录,便于定位污染源,同时避免被毒化客户端的 + // 高频重试刷屏(无重置头的 429 会落到 5 秒兜底冷却,重试相当密集)。 + if !uaAcceptable && clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { + logger.LegacyPrintf("service.identity", + "Rejected fingerprint user-agent for account %d: %q (malformed or implausible version)", + accountID, clientUA) + } + + if !isAcceptableFingerprintUserAgent(cached.UserAgent) { + // 自愈:缓存中已是畸形/哨兵 UA(本次加固之前写入的)。指纹在活跃账号上 + // 懒续期后近乎永不过期,且系统内没有重置入口——不在读取时纠正,存量被 + // 毒化的账号就只能靠手工删 Redis 键恢复。 + poisoned := cached.UserAgent + if uaAcceptable { + mergeHeadersIntoFingerprint(cached, headers) + } else { + cached.UserAgent = defaultFingerprint.UserAgent + } + needWrite = true + logger.LegacyPrintf("service.identity", + "Replaced malformed cached fingerprint for account %d: %q -> %q", + accountID, poisoned, cached.UserAgent) + } else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) mergeHeadersIntoFingerprint(cached, headers) needWrite = true logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) - } else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { + } + + if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { // 距上次写入超过24小时,续期TTL needWrite = true } @@ -103,7 +179,13 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID return cached, nil } - // 缓存不存在或解析失败,创建新指纹 + // 缓存不存在或解析失败,创建新指纹。首次创建同样是持久化写入, + // 畸形 UA 在这里落库后就成了账号的长期身份,必须同样拒绝。 + if !uaAcceptable && clientUA != "" { + logger.LegacyPrintf("service.identity", + "Rejected fingerprint user-agent for account %d: %q (malformed or implausible version)", + accountID, clientUA) + } fp := s.createFingerprintFromHeaders(headers) // 生成随机ClientID @@ -123,8 +205,9 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fingerprint { fp := &Fingerprint{} - // 获取User-Agent - if ua := headers.Get("User-Agent"); ua != "" { + // 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。 + // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 + if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { fp.UserAgent = ua } else { fp.UserAgent = defaultFingerprint.UserAgent diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go new file mode 100644 index 0000000000..5206eb04f0 --- /dev/null +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -0,0 +1,233 @@ +package service + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +type stubIdentityCache struct { + fingerprint *Fingerprint + setCalls int + lastSet *Fingerprint +} + +func (s *stubIdentityCache) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) { + if s.fingerprint == nil { + return nil, nil + } + clone := *s.fingerprint + return &clone, nil +} + +func (s *stubIdentityCache) SetFingerprint(_ context.Context, _ int64, fp *Fingerprint) error { + s.setCalls++ + clone := *fp + s.lastSet = &clone + s.fingerprint = &clone + return nil +} + +func (s *stubIdentityCache) GetMaskedSessionID(_ context.Context, _ int64) (string, error) { + return "", nil +} + +func (s *stubIdentityCache) SetMaskedSessionID(_ context.Context, _ int64, _ string) error { + return nil +} + +func headersWithUA(ua string) http.Header { + h := http.Header{} + if ua != "" { + h.Set("User-Agent", ua) + } + return h +} + +func TestIsAcceptableFingerprintUserAgent(t *testing.T) { + cases := []struct { + name string + ua string + want bool + }{ + {"official_cli", "claude-cli/2.1.220 (external, cli)", true}, + {"official_cli_no_meta", "claude-cli/2.1.220", true}, + {"next_major_still_allowed", "claude-cli/4.0.0 (external, cli)", true}, + {"other_product_valid_form", "some-sdk/1.2.3 (node)", true}, + + // 本地/开发构建:版本号后带后缀,正是 #5254 的毒化 UA 形态。 + {"local_build_suffix", "claude-cli/999.0.0-local (undefined, cli)", false}, + {"dev_build_suffix", "claude-cli/2.1.220-dev (external, cli)", false}, + {"build_metadata_suffix", "claude-cli/2.1.220+build1 (external, cli)", false}, + + // 哨兵版本号:形态合法但主版本号远超 sub2api 自身伪装版本。 + {"sentinel_major", "claude-cli/999.0.0 (external, cli)", false}, + + {"empty", "", false}, + {"no_version", "claude-cli (external, cli)", false}, + {"two_segment_version", "claude-cli/2.1 (external, cli)", false}, + // 浏览器 UA 是两段版本号,不符合三段 semver 形态:不适合作为账号身份。 + {"browser_ua_two_segment", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", false}, + {"leading_junk", "x claude-cli/2.1.220 (external, cli)", false}, + {"too_long", "claude-cli/2.1.220 (" + strings.Repeat("a", 300) + ")", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, isAcceptableFingerprintUserAgent(tc.ua)) + }) + } +} + +// 首次创建路径:畸形 UA 不得被原样持久化,回退默认指纹。 +// 只在 isNewerVersion 处加校验是不够的——删键恢复后账号会被同一客户端立即再次毒化。 +func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent, + "畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份") + require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") +} + +// 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。 +// isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。 +func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { + cached := &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + } + cache := &stubIdentityCache{fingerprint: cached} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent, + "真实指纹不得被哨兵版本覆盖") + require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入") +} + +// 合法的真实版本升级必须照常生效,校验不能把正常升级一起挡掉。 +func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + newUA := "claude-cli/2.1.223 (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) + + require.NoError(t, err) + require.Equal(t, newUA, fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 合法 UA 的首次创建路径不受影响。 +func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + ua := "claude-cli/" + claude.CLICurrentVersion + " (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(ua)) + + require.NoError(t, err) + require.Equal(t, ua, fp.UserAgent) + require.NotEmpty(t, fp.ClientID) + require.Equal(t, 1, cache.setCalls) +} + +// 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。 +func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) { + require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent), + "defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写") +} + +// 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。 +// 指纹在活跃账号上懒续期后近乎永不过期,且系统内没有重置入口—— +// 不在读取时纠正,已中招的账号只能靠手工删 Redis 键恢复。 +func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/999.0.0-local (undefined, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + realUA := "claude-cli/2.1.22 (external, cli)" + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA)) + + require.NoError(t, err) + require.Equal(t, realUA, fp.UserAgent, + "真实客户端必须能从被毒化的指纹手中夺回账号身份") + require.Equal(t, 1, cache.setCalls) + require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") + require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID") +} + +// 毒化指纹 + 同样畸形的客户端 UA:回退默认指纹,不保留任何一方的畸形值。 +func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/999.0.0-local (undefined, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/999.0.0-local (undefined, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。 +func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.22 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent) + require.Zero(t, cache.setCalls) +} + +// 无 UA 时的既有行为(回退默认指纹)保持不变。 +func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{}) + + require.NoError(t, err) + require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) +} diff --git a/backend/internal/service/image_storage.go b/backend/internal/service/image_storage.go index a2f4882fa4..b160846b47 100644 --- a/backend/internal/service/image_storage.go +++ b/backend/internal/service/image_storage.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "strconv" "strings" @@ -126,6 +127,9 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri var rawURL string if err := json.Unmarshal(raw, &rawURL); err == nil { if rawURL = strings.TrimSpace(rawURL); rawURL != "" { + if len(rawURL) >= len("data:") && strings.EqualFold(rawURL[:len("data:")], "data:") { + return u.decodeImageDataURL(rawURL) + } return u.download(ctx, rawURL) } } @@ -133,6 +137,60 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri return nil, "", errors.New("image item has neither b64_json nor url") } +func (u *ImageResultUploader) decodeImageDataURL(rawURL string) ([]byte, string, error) { + header, payload, ok := strings.Cut(rawURL[len("data:"):], ",") + if !ok { + return nil, "", errors.New("decode image data URL: missing comma separator") + } + + parts := strings.Split(header, ";") + if strings.TrimSpace(parts[0]) == "" { + return nil, "", errors.New("decode image data URL: missing media type") + } + base64Index := len(parts) - 1 + if base64Index < 1 || !strings.EqualFold(strings.TrimSpace(parts[base64Index]), "base64") { + for i := 1; i < base64Index; i++ { + if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") { + return nil, "", errors.New("decode image data URL: base64 marker must be the final header token") + } + } + return nil, "", errors.New("decode image data URL: payload is not base64 encoded") + } + for i := 1; i < base64Index; i++ { + if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") { + return nil, "", errors.New("decode image data URL: duplicate base64 marker") + } + } + mediaTypeHeader := strings.Join(parts[:base64Index], ";") + declaredType, _, err := mime.ParseMediaType(mediaTypeHeader) + if err != nil { + return nil, "", fmt.Errorf("decode image data URL: invalid media type: %w", err) + } + declaredType = strings.ToLower(declaredType) + if !strings.HasPrefix(declaredType, "image/") { + return nil, "", fmt.Errorf("decode image data URL: media type %q is not an image", declaredType) + } + + limit := u.maxDownloadBytes + if limit <= 0 { + limit = defaultImageMaxDownloadBytes + } + decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(payload)) + data, err := io.ReadAll(io.LimitReader(decoder, limit+1)) + if err != nil { + return nil, "", fmt.Errorf("decode image data URL base64 payload: %w", err) + } + if int64(len(data)) > limit { + return nil, "", fmt.Errorf("decoded image data URL exceeds %d bytes", limit) + } + + contentType := detectedImageContentType(data) + if contentType == "" { + contentType = declaredType + } + return data, contentType, nil +} + func (u *ImageResultUploader) download(ctx context.Context, rawURL string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { @@ -169,11 +227,18 @@ func (u *ImageResultUploader) buildKey(taskID string, index int, contentType str } func detectImageContentType(data []byte) string { + if ct := detectedImageContentType(data); ct != "" { + return ct + } + return "image/png" +} + +func detectedImageContentType(data []byte) string { ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]) if strings.HasPrefix(ct, "image/") { return ct } - return "image/png" + return "" } func extensionForContentType(ct string) string { diff --git a/backend/internal/service/image_storage_test.go b/backend/internal/service/image_storage_test.go index fc9e5043e5..3a320671ee 100644 --- a/backend/internal/service/image_storage_test.go +++ b/backend/internal/service/image_storage_test.go @@ -29,6 +29,12 @@ type fakeImageStorage struct { err error } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func (f *fakeImageStorage) Save(_ context.Context, key, contentType string, data []byte) (string, error) { if f.err != nil { return "", f.err @@ -91,6 +97,89 @@ func TestImageResultUploaderRewritesURL(t *testing.T) { require.JSONEq(t, `"https://cdn.test/images/imgtask_xyz-0.png"`, string(parsed.Data[0]["url"])) } +func TestImageResultUploaderRewritesImageDataURLWithoutHTTP(t *testing.T) { + httpCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + httpCalls++ + return nil, errors.New("HTTP must not be called for data URLs") + })} + storage := &fakeImageStorage{} + uploader := NewImageResultUploader(storage, "images/", 0, client) + b64 := base64.StdEncoding.EncodeToString(pngBytes) + result := json.RawMessage(`{"data":[{"url":"DATA:image/jpeg;name=photo.jpg;BaSe64,` + b64 + `","revised_prompt":"kept"}]}`) + + out, err := uploader.Rewrite(context.Background(), "imgtask_data", result) + require.NoError(t, err) + require.Zero(t, httpCalls) + require.Len(t, storage.saved, 1) + require.Equal(t, pngBytes, storage.saved[0].data) + require.Equal(t, "image/png", storage.saved[0].contentType, "detected bytes take precedence over a conflicting declaration") + require.Equal(t, "images/imgtask_data-0.png", storage.saved[0].key) + + var parsed struct { + Data []map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(out, &parsed)) + require.JSONEq(t, `"https://cdn.test/images/imgtask_data-0.png"`, string(parsed.Data[0]["url"])) + require.JSONEq(t, `"kept"`, string(parsed.Data[0]["revised_prompt"])) +} + +func TestImageResultUploaderDataURLValidation(t *testing.T) { + tests := []struct { + name string + url string + wantErr string + }{ + {name: "missing comma", url: "data:image/png;base64", wantErr: "missing comma"}, + {name: "non image", url: "data:text/plain;base64,aGVsbG8=", wantErr: "is not an image"}, + {name: "non base64", url: "data:image/png,raw", wantErr: "not base64"}, + {name: "invalid base64", url: "data:image/png;base64,%%%", wantErr: "base64 payload"}, + {name: "invalid media type", url: "data:image/png;bad parameter;base64,aGVsbG8=", wantErr: "invalid media type"}, + {name: "parameter after base64", url: "data:image/png;base64;name=photo.png,aGVsbG8=", wantErr: "base64 marker must be the final header token"}, + {name: "duplicate base64 marker", url: "data:image/png;base64;base64,aGVsbG8=", wantErr: "duplicate base64 marker"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + httpCalls++ + return nil, errors.New("HTTP must not be called for data URLs") + })} + uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, client) + result, err := json.Marshal(map[string]any{"data": []map[string]string{{"url": tt.url}}}) + require.NoError(t, err) + + _, err = uploader.Rewrite(context.Background(), "imgtask_bad", result) + require.ErrorContains(t, err, tt.wantErr) + require.Zero(t, httpCalls) + }) + } +} + +func TestImageResultUploaderRejectsOversizedImageDataURL(t *testing.T) { + storage := &fakeImageStorage{} + uploader := NewImageResultUploader(storage, "images/", 3, nil) + payload := base64.StdEncoding.EncodeToString([]byte("four")) + result := json.RawMessage(`{"data":[{"url":"data:image/png;base64,` + payload + `"}]}`) + + _, err := uploader.Rewrite(context.Background(), "imgtask_large", result) + require.ErrorContains(t, err, "decoded image data URL exceeds 3 bytes") + require.Empty(t, storage.saved) +} + +func TestImageResultUploaderB64JSONTakesPrecedenceOverDataURL(t *testing.T) { + storage := &fakeImageStorage{} + uploader := NewImageResultUploader(storage, "images/", 0, nil) + b64 := base64.StdEncoding.EncodeToString(pngBytes) + result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `","url":"data:text/plain,not-an-image"}]}`) + + _, err := uploader.Rewrite(context.Background(), "imgtask_precedence", result) + require.NoError(t, err) + require.Len(t, storage.saved, 1) + require.Equal(t, pngBytes, storage.saved[0].data) +} + func TestImageResultUploaderPropagatesStorageError(t *testing.T) { storage := &fakeImageStorage{err: errors.New("bucket unreachable")} uploader := NewImageResultUploader(storage, "images/", 0, nil) diff --git a/backend/internal/service/media_price_config.go b/backend/internal/service/media_price_config.go index 0a583b6c72..53d63c97a4 100644 --- a/backend/internal/service/media_price_config.go +++ b/backend/internal/service/media_price_config.go @@ -20,14 +20,15 @@ func videoPriceConfigFromAPIKey(apiKey *APIKey) *VideoPriceConfig { return nil } return &VideoPriceConfig{ - Price480P: apiKey.Group.VideoPrice480P, - Price720P: apiKey.Group.VideoPrice720P, - Price1080P: apiKey.Group.VideoPrice1080P, + Price480P: apiKey.Group.VideoPrice480P, + Price720P: apiKey.Group.VideoPrice720P, + Price1080P: apiKey.Group.VideoPrice1080P, + ModelPrices: apiKey.Group.VideoModelPrices, } } -func apiKeyHasConfiguredVideoPrice(apiKey *APIKey, resolution string) bool { - return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetVideoPrice(resolution) != nil +func apiKeyHasConfiguredVideoPrice(apiKey *APIKey, model, resolution string) bool { + return apiKey != nil && apiKey.Group != nil && apiKey.Group.GetVideoPriceForModel(model, resolution) != nil } func webSearchPricePerCallFromAPIKey(apiKey *APIKey) *float64 { @@ -36,3 +37,22 @@ func webSearchPricePerCallFromAPIKey(apiKey *APIKey) *float64 { } return apiKey.Group.WebSearchPricePerCall } + +func groupSearchPricePer1kFromAPIKey(apiKey *APIKey) *float64 { + if apiKey == nil || apiKey.Group == nil { + return nil + } + return apiKey.Group.GetSearchPricePer1k() +} + +func groupAudioPriceConfigFromAPIKey(apiKey *APIKey) *audioPriceConfig { + if apiKey == nil || apiKey.Group == nil { + return nil + } + g := apiKey.Group + return &audioPriceConfig{ + RealtimePerMin: g.AudioRealtimePricePerMin, + TTSPerMChars: g.AudioTTSPricePerMillionChars, + STTPerHour: g.AudioSTTPricePerHour, + } +} diff --git a/backend/internal/service/model_rate_limit.go b/backend/internal/service/model_rate_limit.go index 195f5a1dfd..fe962b17c0 100644 --- a/backend/internal/service/model_rate_limit.go +++ b/backend/internal/service/model_rate_limit.go @@ -120,6 +120,23 @@ func OpenAIImageGenerationIntentFromContext(ctx context.Context) bool { return ok && enabled } +// WithOpenAIImagesEndpoint 标记请求从 /v1/images/* 专用生图端点入站。 +func WithOpenAIImagesEndpoint(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, ctxkey.OpenAIImagesEndpoint, true) +} + +// OpenAIImagesEndpointFromContext 报告请求是否来自 /v1/images/*。 +func OpenAIImagesEndpointFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + enabled, ok := ctx.Value(ctxkey.OpenAIImagesEndpoint).(bool) + return ok && enabled +} + func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string { modelKey := mapAntigravityModel(account, requestedModel) if modelKey == "" { diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go index 772eb45e9e..9f12761400 100644 --- a/backend/internal/service/notification_email_service_test.go +++ b/backend/internal/service/notification_email_service_test.go @@ -4,7 +4,10 @@ import ( "bufio" "context" "errors" + "io" + "mime/quotedprintable" "net" + "net/mail" "strings" "sync" "sync/atomic" @@ -598,6 +601,21 @@ func (s *notificationEmailTestSMTPServer) lastMessage() string { return s.messageBodies[len(s.messageBodies)-1] } +func (s *notificationEmailTestSMTPServer) lastMessageBody(t *testing.T) string { + t.Helper() + + message, err := mail.ReadMessage(strings.NewReader(s.lastMessage())) + require.NoError(t, err) + + bodyReader := io.Reader(message.Body) + if strings.EqualFold(message.Header.Get("Content-Transfer-Encoding"), "quoted-printable") { + bodyReader = quotedprintable.NewReader(message.Body) + } + body, err := io.ReadAll(bodyReader) + require.NoError(t, err) + return string(body) +} + func (s *notificationEmailTestSMTPServer) close() { _ = s.listener.Close() s.wg.Wait() diff --git a/backend/internal/service/oauth_service.go b/backend/internal/service/oauth_service.go index 0b3888a73f..b92d5cc009 100644 --- a/backend/internal/service/oauth_service.go +++ b/backend/internal/service/oauth_service.go @@ -22,6 +22,9 @@ type OpenAIOAuthClient interface { type GrokOAuthClient interface { ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error) + // LoginWithPassword exchanges email/password for a short-lived Web SSO cookie. + // Callers must convert via ConvertSSOToBuild and must not persist password or raw SSO. + LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*GrokPasswordLoginResult, error) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) } diff --git a/backend/internal/service/openai_account_model_transient.go b/backend/internal/service/openai_account_model_transient.go index 6ef710d23c..60fcff9053 100644 --- a/backend/internal/service/openai_account_model_transient.go +++ b/backend/internal/service/openai_account_model_transient.go @@ -7,7 +7,19 @@ import ( ) const ( - openAIModelTransientFailureWindow = time.Minute + // openAIModelTransientStreakTTL bounds how long a failure streak survives + // without a new failure. It exists only so the map does not keep state for + // account+model pairs that stopped being used; a streak is otherwise reset + // by recordSuccess alone. + // + // It must stay well above the cooldowns. Resetting the streak on a short + // wall-clock window makes the breaker's sensitivity depend on request rate: + // a gateway called less often than the window never reaches streak 2, so a + // broken upstream is never cooled down and every request pays a failed + // attempt plus a failover before reaching a healthy account. Low-traffic + // deployments were hit hardest, which is the opposite of what a breaker + // should do. + openAIModelTransientStreakTTL = 30 * time.Minute openAIModelTransientShortCooldown = 10 * time.Second openAIModelTransientLongCooldown = 45 * time.Second openAIModelTransientDefaultMax = 4096 @@ -86,7 +98,9 @@ func (s *openAIAccountModelTransientState) recordFailure(accountID int64, model if !exists { s.evictOldestLocked() } - if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow || now.Before(entry.lastFailure) { + // The streak is cleared by recordSuccess. Only drop it here when the entry + // is stale beyond the TTL, or when the clock moved backwards. + if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL || now.Before(entry.lastFailure) { entry.failureStreak = 0 entry.blockUntil = time.Time{} } @@ -139,7 +153,7 @@ func (s *openAIAccountModelTransientState) isBlocked(accountID int64, model stri if !exists { return false } - if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow { + if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL { delete(s.entries, key) return false } diff --git a/backend/internal/service/openai_account_model_transient_test.go b/backend/internal/service/openai_account_model_transient_test.go index bd8d92f4d2..f395883045 100644 --- a/backend/internal/service/openai_account_model_transient_test.go +++ b/backend/internal/service/openai_account_model_transient_test.go @@ -80,12 +80,52 @@ func TestOpenAIModelTransient_StaleStreakExpires(t *testing.T) { now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) state.recordFailure(35, "gpt-5.5", now) - decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientFailureWindow+time.Second)) + decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientStreakTTL+time.Second)) assert.Equal(t, 1, decision.FailureStreak) assert.Zero(t, decision.Cooldown) } +// A streak must not depend on how often the gateway is called. Sparse traffic +// used to reset the streak between every request, so a broken account+model was +// never cooled down and each request paid a failed attempt plus a failover. +func TestOpenAIModelTransient_StreakSurvivesSparseTraffic(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + gap := 5 * time.Minute + require.Greater(t, gap, openAIModelTransientLongCooldown, + "the gap must exceed every cooldown, otherwise this passes for the wrong reason") + + first := state.recordFailure(35, "gpt-5.5", now) + second := state.recordFailure(35, "gpt-5.5", now.Add(gap)) + third := state.recordFailure(35, "gpt-5.5", now.Add(2*gap)) + + assert.Equal(t, 1, first.FailureStreak) + assert.Zero(t, first.Cooldown) + assert.Equal(t, 2, second.FailureStreak) + assert.Equal(t, openAIModelTransientShortCooldown, second.Cooldown) + assert.Equal(t, 3, third.FailureStreak) + assert.Equal(t, openAIModelTransientLongCooldown, third.Cooldown) + assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*gap+time.Second))) +} + +// A success between two sparse failures still clears the streak, so an account +// that intermittently works is not pushed into the long cooldown. +func TestOpenAIModelTransient_SuccessResetsStreakAcrossSparseTraffic(t *testing.T) { + state := newOpenAIAccountModelTransientState(128) + now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) + gap := 5 * time.Minute + + state.recordFailure(35, "gpt-5.5", now) + state.recordSuccess(35, "gpt-5.5") + + decision := state.recordFailure(35, "gpt-5.5", now.Add(gap)) + + assert.Equal(t, 1, decision.FailureStreak) + assert.Zero(t, decision.Cooldown) + assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(gap+time.Second))) +} + func TestOpenAIModelTransient_IgnoresInvalidKeys(t *testing.T) { state := newOpenAIAccountModelTransientState(128) now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 870a6e0ef3..6457ef8fc2 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -3,6 +3,7 @@ package service import ( "container/heap" "context" + "errors" "fmt" "hash/fnv" "log/slog" @@ -283,9 +284,10 @@ func (s *openAIAccountRuntimeStats) size() int { } type defaultOpenAIAccountScheduler struct { - service *OpenAIGatewayService - metrics openAIAccountSchedulerMetrics - stats *openAIAccountRuntimeStats + service *OpenAIGatewayService + metrics openAIAccountSchedulerMetrics + stats *openAIAccountRuntimeStats + grokFreeQuotaGateCache sync.Map // key: int64(accountID), value: grokFreeQuotaGateCacheEntry } type openAISelectionProbeBudget struct { @@ -405,7 +407,7 @@ func (s *defaultOpenAIAccountScheduler) Select( decision.SelectedAccountID = selection.Account.ID decision.SelectedAccountType = selection.Account.Type if req.SessionHash != "" { - _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, selection.Account.ID) + _ = s.service.bindOpenAIStickySessionDuringSelection(ctx, req.GroupID, req.SessionHash, selection.Account.ID) } return selection, decision, nil } @@ -498,6 +500,23 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) 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) + 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) + return nil, false, nil + } + if account != nil && isGrokModelQuotaBlocked(account.ID, upstreamModel, now) { + _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) + return nil, false, nil + } escapeCfg := s.service.openAIStickyEscapeConfig() if reason, errorRate, ttft, shouldEscape := s.shouldEscapeStickyAccount(accountID, escapeCfg); shouldEscape { slog.Info("sticky_escape_triggered", @@ -511,11 +530,11 @@ 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()) - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, Acquired: true, ReleaseFunc: result.ReleaseFunc, - }, false, nil + }), false, nil } cfg := s.service.schedulingConfig() @@ -531,7 +550,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( ) return nil, true, nil } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, WaitPlan: &AccountWaitPlan{ AccountID: accountID, @@ -539,7 +558,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }, - }, false, nil + }), false, nil } return nil, false, nil } @@ -1167,13 +1186,13 @@ func (s *defaultOpenAIAccountScheduler) tryAcquireOpenAISelectionOrderWithBudget } } if req.SessionHash != "" && !req.PreserveStickyBinding { - _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, fresh.ID) + _ = s.service.bindOpenAIStickySessionDuringSelection(ctx, req.GroupID, req.SessionHash, fresh.ID) } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: fresh, Acquired: true, ReleaseFunc: result.ReleaseFunc, - }, compactBlocked, nil + }), compactBlocked, nil } return nil, compactBlocked, nil } @@ -1240,23 +1259,35 @@ func (s *defaultOpenAIAccountScheduler) tryFallbackToWeightedSticky( if req.RequireCompact && openAICompactSupportTier(account) == 0 { continue } + // Keep weighted sticky fallback subject to the same free-tier gate as the + // normal and sticky selection paths. Otherwise an over-quota free account + // could be reintroduced after the primary candidate pass. + if len(s.filterGrokFreeQuotaAccounts(ctx, []Account{*account})) == 0 { + continue + } + upstreamModel := canonicalOpenAIAccountSchedulingModel(account, req.RequestedModel) + now := time.Now() + if isGrokTeamModelRateLimited(account, upstreamModel, now) || + isGrokModelQuotaBlocked(account.ID, upstreamModel, now) { + continue + } result, acquireErr := s.service.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) if acquireErr != nil { return nil, acquireErr } if result != nil && result.Acquired { if req.SessionHash != "" && !req.PreserveStickyBinding { - _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, account.ID) + _ = s.service.bindOpenAIStickySessionDuringSelection(ctx, req.GroupID, req.SessionHash, account.ID) } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, Acquired: true, ReleaseFunc: result.ReleaseFunc, - }, nil + }), nil } if s.service.concurrencyService != nil { cfg := s.service.schedulingConfig() - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, WaitPlan: &AccountWaitPlan{ AccountID: account.ID, @@ -1264,7 +1295,7 @@ func (s *defaultOpenAIAccountScheduler) tryFallbackToWeightedSticky( Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }, - }, nil + }), nil } } return nil, nil @@ -1329,6 +1360,28 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( if len(accounts) == 0 { return nil, 0, 0, 0, noAvailableOpenAISelectionError(req.RequestedModel, false, openAISelectionFilterStats{}.summary("")) } + // Local free-tier soft gate on the Grok scheduling path only (not admin probe). + accounts = s.filterGrokFreeQuotaAccounts(ctx, accounts) + if len(accounts) == 0 { + return nil, 0, 0, 0, noAvailableOpenAISelectionError(req.RequestedModel, false, openAISelectionFilterStats{}.summary("grok_free_quota_soft_gate")) + } + // Team+model rate-limit cool: siblings of a 429'd team skip the hot model. + if req.Platform == PlatformGrok { + now := time.Now() + filtered := filterGrokTeamModelRateLimitedAccounts(accounts, req.RequestedModel, now) + if len(filtered) == 0 && len(accounts) > 0 { + return nil, 0, 0, 0, noAvailableOpenAISelectionError(req.RequestedModel, false, openAISelectionFilterStats{}.summary("grok_team_model_rate_limit")) + } + if filtered != nil { + accounts = filtered + } + // Per-account model free-usage soft-block (other models stay eligible). + modelFiltered := filterGrokModelQuotaBlockedAccounts(accounts, req.RequestedModel, now) + if len(modelFiltered) == 0 && len(accounts) > 0 { + return nil, 0, 0, 0, noAvailableOpenAISelectionError(req.RequestedModel, false, openAISelectionFilterStats{}.summary("grok_model_quota_block")) + } + accounts = modelFiltered + } // require_privacy_set: 获取分组信息 var schedGroup *Group @@ -1621,7 +1674,7 @@ func (s *defaultOpenAIAccountScheduler) finishLoadBalanceSelectionFallback( compactBlocked = true continue } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: fresh, WaitPlan: &AccountWaitPlan{ AccountID: fresh.ID, @@ -1629,7 +1682,7 @@ func (s *defaultOpenAIAccountScheduler) finishLoadBalanceSelectionFallback( Timeout: cfg.FallbackWaitTimeout, MaxWaiting: cfg.FallbackMaxWaiting, }, - }, candidateCount, topK, loadSkew, nil + }), candidateCount, topK, loadSkew, nil } } @@ -1678,7 +1731,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlocked(account, req.RequestedModel) { return false, "runtime_blocked" } - if s != nil && s.service != nil && s.service.isOpenAIProxyStreamQuarantined(account) { + if s != nil && s.service != nil && s.service.isOpenAIProxyStreamQuarantined(ctx, account) { return false, "proxy_stream_quarantined" } // Quota auto-pause must be evaluated during the initial filter too. Without it the @@ -1711,6 +1764,11 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if !accountSupportsOpenAICapabilities(account, req.RequiredCapability, req.RequiredImageCapability) { return false, "capability_mismatch" } + // 分组利润控制:不合格账号在候选过滤与抢槽后终检阶段即被排除, + // 排序/评分/粘性/熔断只在合格账号之间工作;named reason 进入 filter stats。 + if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed { + return false, reason + } return true, "" } @@ -2035,6 +2093,13 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( return selection, decision, err } +// selectAccountWithScheduler wraps selectAccountWithSchedulerOnce with a +// fail-open second pass for the proxy stream circuit (#5056): when the only +// reason no account is available is that every candidate sits behind a +// quarantined proxy, the quarantine must degrade to a preference instead of +// zeroing out capacity. The retry re-runs the exact same selection with the +// quarantine checks bypassed, so healthy proxies always win the first pass +// and quarantined ones only serve when nothing else can. func (s *OpenAIGatewayService) selectAccountWithScheduler( ctx context.Context, groupID *int64, @@ -2049,8 +2114,53 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( platform string, previousResponseCanMove bool, useUpstreamTokenCost bool, +) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + selection, decision, err := s.selectAccountWithSchedulerOnce(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, requiredImageCapability, requireCompact, platform, previousResponseCanMove, useUpstreamTokenCost) + if err == nil || openAIProxyStreamQuarantineBypassed(ctx) { + return selection, decision, err + } + if !errors.Is(err, ErrNoAvailableAccounts) && !errors.Is(err, ErrNoAvailableCompactAccounts) { + return selection, decision, err + } + // The circuit only ever quarantines PlatformOpenAI accounts. + if normalizeOpenAICompatiblePlatform(platform) != PlatformOpenAI { + return selection, decision, err + } + blocked := s.getOpenAIProxyStreamCircuit().activeBlockCount(time.Now()) + if blocked == 0 { + return selection, decision, err + } + s.logOpenAIProxyStreamQuarantineFailOpen(requestedModel, blocked) + return s.selectAccountWithSchedulerOnce(withOpenAIProxyStreamQuarantineBypass(ctx), groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, requiredImageCapability, requireCompact, platform, previousResponseCanMove, useUpstreamTokenCost) +} + +func (s *OpenAIGatewayService) selectAccountWithSchedulerOnce( + ctx context.Context, + groupID *int64, + previousResponseID string, + sessionHash string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredTransport OpenAIUpstreamTransport, + requiredCapability OpenAIEndpointCapability, + requiredImageCapability OpenAIImagesCapability, + requireCompact bool, + platform string, + previousResponseCanMove bool, + useUpstreamTokenCost bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + // 分组利润控制:唯一文本调度入口的防御性装门。handler 文本 + // 入口已在请求开始经 WithOpenAIRequestPricingContext 装门并固定 pricingAt, + // 此处对同分组门直接复用(failover 重入阈值稳定),仅为不经 handler 装配的 + // 内部调用兜底。图片/视频调度不在利润门范围:requiredImageCapability 非空的 + // Images 调度不装门;requiredCapability == OpenAIEndpointCapabilityResponses + // 当前仅显式生图意图的 /v1/responses 设置(HTTP openAIResponsesRequiredCapability + // 与 WS 桥同款判定),同样不装门——若未来把该 capability 用于非生图流量, + // 需要同步收窄本条件(有测试钉死该映射)。 + if requiredImageCapability == "" && requiredCapability != OpenAIEndpointCapabilityResponses { + ctx = s.withOpenAIProfitControlGate(ctx, groupID) + } platform = normalizeOpenAICompatiblePlatform(platform) decision := OpenAIAccountScheduleDecision{} scheduler := s.getOpenAIAccountScheduler(ctx) @@ -2611,6 +2721,15 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS var first float64 distinct := false for _, account := range accounts { + if account == nil { + continue + } + // 与 openAIUpstreamCostFactors 使用同一道平台门控:只有 OpenAI 平台账号 + // 的倍率参与 legacy 低倍率优先排序。上游自报倍率来自中转方,不能让它对 + // 其他平台的调度产生影响——否则自报低价即可吸走流量,而实际结算走本地倍率。 + if !account.IsOpenAIApiKey() && !account.IsOpenAIOAuth() { + continue + } rate, ok := openAISchedulingRate(account, now, oauthSchedulingRateMultiplier) if !ok { continue diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 9ffa0799bb..3b3a64856e 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -155,7 +155,7 @@ func (c *schedulerTestGatewayCache) GetSessionAccountID(ctx context.Context, gro if id, ok := c.sessionBindings[sessionHash]; ok { return id, nil } - return 0, errors.New("not found") + return 0, ErrStickySessionNotFound } func (c *schedulerTestGatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error { @@ -182,6 +182,20 @@ func (c *schedulerTestGatewayCache) DeleteSessionAccountID(ctx context.Context, return nil } +func (c *schedulerTestGatewayCache) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (c *schedulerTestGatewayCache) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (c *schedulerTestGatewayCache) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c *schedulerTestGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + func newSchedulerTestOpenAIWSV2Config() *config.Config { cfg := &config.Config{} cfg.Gateway.OpenAIWS.Enabled = true @@ -1475,6 +1489,44 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SkipsQuarantinedSharedP require.Equal(t, int64(469803), selection.Account.ID) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_FailsOpenWhenAllProxiesQuarantined(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + // Every schedulable account shares the quarantined proxy: the circuit must + // degrade to a preference instead of zeroing out capacity (#5056). + proxyA := int64(5056) + accounts := []Account{ + {ID: 505601, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, ProxyID: &proxyA}, + {ID: 505602, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, ProxyID: &proxyA}, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + openaiProxyStreamCircuit: newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }), + } + tripped, _ := svc.openaiProxyStreamCircuit.recordFailure(proxyA, time.Now()) + require.True(t, tripped) + + selection, _, err := svc.SelectAccountWithScheduler( + context.Background(), nil, "", "", "gpt-5.6-sol", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err, "quarantine must fail open instead of returning no available accounts") + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.NotNil(t, selection.Account.ProxyID) + require.Equal(t, proxyA, *selection.Account.ProxyID) + require.True(t, svc.openaiProxyStreamCircuit.isBlocked(proxyA, time.Now()), + "fail-open must not clear the quarantine; only a completed stream or TTL expiry does") +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimitedAccountFallsBackToFreshCandidate(t *testing.T) { ctx := context.Background() groupID := int64(10101) diff --git a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go index 6c32db4ee8..1288107573 100644 --- a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go +++ b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go @@ -491,6 +491,46 @@ func TestOpenAILegacyUpstreamRateOrderRequiresComparableRates(t *testing.T) { require.Negative(t, distinct.compare(&Account{ID: 2}, &Account{ID: 3})) } +// 探测资格已放宽到全部 API-key 平台,但调度侧的信任面没有跟着扩大: +// 只有 OpenAI 平台账号的上游自报倍率参与 legacy 低倍率优先排序, +// 否则中转方自报低价即可吸走流量,而实际结算走本地倍率。 +// 本用例钉死 newOpenAILegacyUpstreamRateOrder 与 openAIUpstreamCostFactors +// 使用同一道平台门控。 +func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) { + now := time.Now() + nonOpenAI := func(id int64, platform string, rate float64) *Account { + account := upstreamCostTestAccount(id, UpstreamBillingProbeStatusOK, rate, now.Add(-time.Minute), 30*time.Minute) + account.Platform = platform + return account + } + grokCheap := nonOpenAI(1, PlatformGrok, 0.01) + anthropicExpensive := nonOpenAI(2, PlatformAnthropic, 0.9) + + order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + require.False(t, order.enabled) + require.Empty(t, order.rates) + require.Zero(t, order.compare(grokCheap, anthropicExpensive)) + + factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, openAIUpstreamCostNeutralFactor, factors[grokCheap.ID]) + require.Equal(t, openAIUpstreamCostNeutralFactor, factors[anthropicExpensive.ID]) + + // 混合候选集里,非 OpenAI 账号既不进 rates 也不影响 OpenAI 账号之间的排序。 + openAICheap := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute) + openAIExpensive := upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute) + mixed := newOpenAILegacyUpstreamRateOrder( + []*Account{grokCheap, openAICheap, anthropicExpensive, openAIExpensive}, + now, defaultOpenAIOAuthSchedulingRateMultiplier, + ) + require.True(t, mixed.enabled) + require.Len(t, mixed.rates, 2) + require.NotContains(t, mixed.rates, grokCheap.ID) + require.NotContains(t, mixed.rates, anthropicExpensive.ID) + require.Negative(t, mixed.compare(openAICheap, openAIExpensive)) + // 自报 0.01 的 grok 账号没有已知倍率,排在有倍率的 OpenAI 账号之后。 + require.Positive(t, mixed.compare(grokCheap, openAIExpensive)) +} + func TestOpenAISchedulingRatePlacesOAuthAtConfiguredReference(t *testing.T) { now := time.Now() cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.02, now.Add(-time.Minute), 30*time.Minute) diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 488db038e0..0ddc8f5255 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) @@ -246,7 +247,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", "codex_cli_rs") + req.Header.Set("Originator", openai.CodexDefaultOriginator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) @@ -264,8 +265,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c req.Header.Set("Session_ID", isolated) req.Header.Set("Conversation_ID", isolated) } - s.overrideBrowserUserAgent(ctx, account, req) - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil } @@ -390,7 +390,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" { req.Header.Set("Originator", originator) } else { - req.Header.Set("Originator", "codex_cli_rs") + req.Header.Set("Originator", openai.CodexDefaultOriginator) } if customUA := account.GetOpenAIUserAgent(); customUA != "" { req.Header.Set("User-Agent", customUA) @@ -402,8 +402,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("User-Agent", codexCLIUserAgent) } - s.overrideBrowserUserAgent(ctx, account, req) - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } account.ApplyHeaderOverrides(req.Header) diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index e31fd9e01d..c4eded7d59 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -88,7 +89,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("chatgpt-account-id")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) - require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) require.JSONEq(t, string(body), string(upstream.lastBody)) } @@ -153,9 +154,9 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { 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, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) - require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version")) + 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, "codex_cli_rs", upstream.lastReq.Header.Get("Originator")) + 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")) require.Empty(t, upstream.lastReq.Header.Get(responsesLiteHeaderKey)) diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go new file mode 100644 index 0000000000..e68a48db1c --- /dev/null +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// --- mock: 只记录临时不可调度写入,其余方法不应被调用 --- + +type capacityShedAccountRepoStub struct { + AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + tempUnschedCalls int +} + +func (r *capacityShedAccountRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error { + r.tempUnschedCalls++ + return nil +} + +// 上游容量降载是请求级信号:故障因素(客户端身份、模型容量)与账号无关, +// 同账号重试用尽后不得把账号临时摘掉——否则一个被降载的请求会顺着 failover +// 把整池账号逐个封禁,而每个账号都会以同一个错误失败。 +func TestTempUnscheduleRetryableErrorSkipsRequestScopedTransient(t *testing.T) { + t.Run("请求级瞬时故障不写账号状态", func(t *testing.T) { + repo := &capacityShedAccountRepoStub{} + svc := &GatewayService{accountRepo: repo} + + svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + RetryableOnSameAccount: true, + RequestScopedTransient: true, + }) + + require.Zero(t, repo.tempUnschedCalls) + }) + + // 对照组:同样的 502 在未标记请求级瞬时故障时仍按原有语义临时摘号, + // 确认上面的断言来自新增守卫而非其他前置条件。 + t.Run("未标记时保持原有临时摘号语义", func(t *testing.T) { + repo := &capacityShedAccountRepoStub{} + svc := &GatewayService{accountRepo: repo} + + svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + RetryableOnSameAccount: true, + }) + + require.Equal(t, 1, repo.tempUnschedCalls) + }) +} + +// 非池模式账号同样要先在同账号重试:换号不改变降载因素。 +func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) { + nonPool := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + for _, code := range []string{"server_is_overloaded", "slow_down"} { + payload := []byte(`{"type":"response.failed","response":{"error":{"code":"` + code + `"}}}`) + require.True(t, isOpenAIUpstreamCapacityShedEvent(payload), code) + require.True(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, payload, "overloaded"), code) + } + + // 非降载的 failed 事件在非池模式下仍不做同账号重试,避免放大改动面。 + other := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error"}}}`) + require.False(t, isOpenAIUpstreamCapacityShedEvent(other)) + require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom")) +} + +// 上游降载的真实序列是「event: error → event: response.failed」。error 帧不算 +// 客户端输出:若把它当首输出 flush,clientOutputStarted 被固化,随后的 failed +// 事件就进不了 pre-output failover 分支,只能把致命错误原样转发给客户端。 +func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) { + cases := []struct { + data string + eventType string + want bool + }{ + {`{"type":"error","error":{"code":"server_is_overloaded","message":"overloaded"}}`, "error", false}, + {`{"type":"error","error":{"code":"slow_down","message":"slow down"}}`, "error", false}, + {`{"type":"error","error":{"type":"rate_limit_error","code":"rate_limit_exceeded","message":"limited"}}`, "error", false}, + // 不可重试类错误帧维持原样转发(不进 failover),保留上游错误细节。 + {`{"type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"blocked"}}`, "error", true}, + {`{"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_text.delta","delta":"hi"}`, "response.output_text.delta", true}, + {`[DONE]`, "", true}, + } + for _, tc := range cases { + require.Equal(t, tc.want, openAIStreamDataStartsClientOutput(tc.data, tc.eventType), "data=%s type=%s", tc.data, tc.eventType) + } +} + +// 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。 +// 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。 +func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + } + svc := &OpenAIGatewayService{cfg: cfg} + + 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(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"},"sequence_number":0}`, + "", + "event: response.in_progress", + `data: {"type":"response.in_progress","response":{"id":"resp_1"},"sequence_number":1}`, + "", + "event: error", + `data: {"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."},"sequence_number":2}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}},"sequence_number":3}`, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-shed-error-then-failed"}}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"}, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) +} + +// 流中途(已有真实输出)降载时无法再 failover,此时必须把降载码改写为客户端 +// 可重试的 server_error 再转发——Codex 对 server_is_overloaded/slow_down 判致命 +// 并终止会话,对其余错误码执行内置退避重试。消息原样保留。 +func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + } + svc := &OpenAIGatewayService{cfg: cfg} + + 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(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + "event: error", + `data: {"type":"error","error":{"type":"service_unavailable_error","code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."},"sequence_number":2}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}},"sequence_number":3}`, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-shed-after-output"}}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"}, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + + body := rec.Body.String() + require.Contains(t, body, "partial") + require.Contains(t, body, "event: response.failed") + require.Contains(t, body, `"code":"server_error"`) + require.NotContains(t, body, "server_is_overloaded") + require.Contains(t, body, "Our servers are currently overloaded") +} + +// helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端 +// 依赖其原码解析重试延时)必须原样保留。 +func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) { + cases := []struct { + name string + payload string + wantChanged bool + wantContain string + }{ + { + name: "failed事件嵌套code改写", + payload: `{"type":"response.failed","response":{"error":{"code":"server_is_overloaded","message":"overloaded"}}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, + { + name: "error帧裸code改写", + payload: `{"type":"error","error":{"code":"slow_down","message":"slow down"}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, + { + name: "rate_limit不改写", + payload: `{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"try again in 3s"}}}`, + wantChanged: false, + wantContain: `"code":"rate_limit_exceeded"`, + }, + { + name: "普通server_error不改写", + payload: `{"type":"response.failed","response":{"error":{"code":"server_error","message":"boom"}}}`, + wantChanged: false, + wantContain: `"code":"server_error"`, + }, + { + name: "非JSON不改写", + payload: `not-json`, + wantChanged: false, + wantContain: `not-json`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, changed := sanitizeOpenAICapacityShedErrorCodeForClient([]byte(tc.payload)) + require.Equal(t, tc.wantChanged, changed) + require.Contains(t, string(out), tc.wantContain) + if changed { + require.NotContains(t, string(out), "server_is_overloaded") + require.NotContains(t, string(out), "slow_down") + } + }) + } +} + +// 出站身份的版本声明只能有一个来源:UA 的版本段、version 头、探针版本三处必须同源, +// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。 +func TestCodexOutboundVersionHasSingleSource(t *testing.T) { + require.True(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_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 369f391852..510ee07cf2 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -2,7 +2,10 @@ package service import ( "net/http" + "regexp" "strings" + "sync" + "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/google/uuid" @@ -12,21 +15,142 @@ import ( // 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。 const codexUpstreamMinVersion = "0.144.0" +// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。 +const codexClientVersionMaxLen = 64 + +// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。 +var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) + +// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。 +// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或 +// 自动同步拿到异常值时把不可控内容透给上游。 +func NormalizeCodexClientVersion(version string) string { + version = strings.TrimSpace(version) + if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) { + return "" + } + return version +} + +// buildCodexCLIUserAgent 按版本号拼出规范 Codex TUI User-Agent。 +// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。 +func buildCodexCLIUserAgent(version string) string { + if version = NormalizeCodexClientVersion(version); version == "" { + return codexCLIUserAgent + } + return openai.CodexDefaultOriginator + "/" + version + codexCLIUserAgentSuffix +} + +// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份, +// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。 +// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 +// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有 +// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。 +var codexIdentityEnforcement = func() *atomic.Bool { + v := &atomic.Bool{} + v.Store(true) + return v +}() + +// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。 +// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置, +// 故由持有配置的服务在构造时发布进程级快照。 +func SetCodexIdentityEnforcementEnabled(enabled bool) { + codexIdentityEnforcement.Store(enabled) +} + +// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。 +// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。 +type codexCanonicalUserAgentResolver func() string + +var ( + codexCanonicalUAMu sync.RWMutex + codexCanonicalUAResolver codexCanonicalUserAgentResolver +) + +// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。 +// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。 +func SetCodexCanonicalUserAgentResolver(resolver func() string) { + codexCanonicalUAMu.Lock() + defer codexCanonicalUAMu.Unlock() + codexCanonicalUAResolver = resolver +} + +// codexCanonicalUserAgent 返回出站规范 User-Agent。 +func codexCanonicalUserAgent() string { + codexCanonicalUAMu.RLock() + resolver := codexCanonicalUAResolver + codexCanonicalUAMu.RUnlock() + if resolver != nil { + if ua := strings.TrimSpace(resolver()); ua != "" { + return ua + } + } + return codexCLIUserAgent +} + +// codexOutboundIdentity 出站身份三元组,三者必须同源自洽: +// originator 与 User-Agent 首段配套(否则上游 404,issue #3901), +// version 等于 User-Agent 的版本段且不低于上游门槛。 +type codexOutboundIdentity struct { + userAgent string + originator string + version string +} + +// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。 +// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 TUI 身份。 +// +// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹, +// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份 +// 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。 +// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 +func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity { + canonical := codexCanonicalUserAgent() + ua := strings.TrimSpace(candidateUA) + if ua == "" { + ua = canonical + } + originator, pairedUA, ok := openai.PairCodexClientIdentity(ua) + if !ok { + if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok { + originator, pairedUA = openai.CodexDefaultOriginator, codexCLIUserAgent + } + } + // 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见 + // SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。 + version := codexClientVersionFromUA(canonical) + if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" { + pairedUA = rebuilt + } + return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version} +} + +// codexClientVersionFromUA 取 UA 的版本段作为生效版本; +// 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。 +func codexClientVersionFromUA(ua string) string { + version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua)) + if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 { + return codexCLIVersion + } + return version +} + // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。 -// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders -// 做官方身份配对与最低版本校正。 +// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。 func ensureCodexIdentityHeaders(h http.Header) { if h == nil { return } + identity := resolveCodexOutboundIdentity("") if strings.TrimSpace(h.Get("user-agent")) == "" { - h.Set("user-agent", codexCLIUserAgent) + h.Set("user-agent", identity.userAgent) } if strings.TrimSpace(h.Get("originator")) == "" { - h.Set("originator", "codex_cli_rs") + h.Set("originator", identity.originator) } if strings.TrimSpace(h.Get("version")) == "" { - h.Set("version", codexCLIVersion) + h.Set("version", identity.version) } h.Set("OpenAI-Beta", "responses=experimental") } @@ -41,20 +165,47 @@ func applyOpenAICodexProbeHeaders(h http.Header) { } // enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。 -// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带) -// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套 -// originator;推导不出官方身份(第三方 UA / UA 缺失)时整体回退为默认 Codex CLI 身份。 -// -// 仅对携带 originator 的请求生效;需要从缺失身份头恢复的调用方应先调用 -// ensureCodexIdentityHeaders。 -// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。 +// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。 func enforceCodexIdentityHeaders(h http.Header) { + enforceCodexIdentityHeadersWithUA(h, "") +} + +// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version +// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级 +// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着 +// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。 +// +// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与 +// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。 +// +// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对 +// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。 +// +// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator, +// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。 +// 必须在所有 User-Agent 改写之后调用。 +func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) { if h == nil || h.Get("originator") == "" { return } + if !codexIdentityEnforcement.Load() { + pairCodexIdentityHeaders(h) + return + } + identity := resolveCodexOutboundIdentity(overrideUA) + h.Set("user-agent", identity.userAgent) + h.Set("originator", identity.originator) + h.Set("version", identity.version) +} + +// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份, +// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。 +func pairCodexIdentityHeaders(h http.Header) { originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent")) if !ok { - originator, pairedUA = "codex_cli_rs", codexCLIUserAgent + identity := resolveCodexOutboundIdentity("") + originator, pairedUA = identity.originator, identity.userAgent + h.Set("version", identity.version) } h.Set("user-agent", pairedUA) h.Set("originator", originator) diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index f2794ff254..9b295241ce 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -4,18 +4,23 @@ import ( "net/http" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" ) func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) { t.Helper() require.Equal(t, codexCLIUserAgent, h.Get("User-Agent")) - require.Equal(t, "codex_cli_rs", h.Get("Originator")) + require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator")) require.Equal(t, codexCLIVersion, h.Get("Version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) require.NotEmpty(t, h.Get("X-Codex-Window-ID")) } +// 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version +// 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求 +// 带着第三方或陈旧身份出站。 func TestEnsureCodexIdentityHeaders(t *testing.T) { t.Run("补齐缺失身份头", func(t *testing.T) { h := make(http.Header) @@ -23,108 +28,82 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) { ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) - require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) - t.Run("保留已有官方UA和合法version并重新配对", func(t *testing.T) { - const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)" + t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) { h := make(http.Header) - h.Set("user-agent", tuiUA) + h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)") h.Set("version", "9.9.9") h.Set("OpenAI-Beta", "assistants=v2") ensureCodexIdentityHeaders(h) enforceCodexIdentityHeaders(h) - require.Equal(t, "codex-tui", h.Get("originator")) - require.Equal(t, tuiUA, h.Get("user-agent")) - require.Equal(t, "9.9.9", h.Get("version")) + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta")) }) } func TestEnforceCodexIdentityHeaders(t *testing.T) { - const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)" - tests := []struct { - name string - originator string - userAgent string - version string - wantOriginator string - wantUA string - wantVersion string + name string + originator string + userAgent string + version string }{ { - name: "错配 originator 按最终 UA 重配", - originator: "codex_cli_rs", - userAgent: tuiUA, - wantOriginator: "codex-tui", - wantUA: tuiUA, + name: "TUI 身份", + originator: "codex-tui", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { - name: "官方配套身份原样保留", - originator: "codex-tui", - userAgent: tuiUA, - wantOriginator: "codex-tui", - wantUA: tuiUA, + name: "错配 originator", + originator: "codex_cli_rs", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", }, { - name: "第三方 UA 整体回退默认身份", - originator: "opencode", - userAgent: "luna/1.0.0", - wantOriginator: "codex_cli_rs", - wantUA: codexCLIUserAgent, + name: "官方 vscode 身份", + originator: "codex_vscode", + userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)", }, { - name: "UA 缺失回退默认身份", - originator: "codex_vscode", - wantOriginator: "codex_cli_rs", - wantUA: codexCLIUserAgent, + name: "第三方客户端身份", + originator: "opencode", + userAgent: "luna/1.0.0", + version: "2.1.0", }, { - name: "originator override UA 首段被尾部真实身份重写", - originator: "cccc", - userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", - wantOriginator: "codex-tui", - wantUA: "codex-tui/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", + name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)", + originator: "codex_cli_rs", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, { - name: "低于门槛的 version 提升为内置版本", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.125.0", - version: "0.125.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.125.0", - wantVersion: codexCLIVersion, + name: "UA 缺失", + originator: "codex_vscode", }, { - name: "达标 version 原样保留", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.145.0", - version: "0.145.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.145.0", - wantVersion: "0.145.0", + name: "originator override 的真实 TUI 客户端", + originator: "cccc", + userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", }, { - name: "未携带 version 不注入", - originator: "codex_cli_rs", - userAgent: "codex_cli_rs/0.98.0", - wantOriginator: "codex_cli_rs", - wantUA: "codex_cli_rs/0.98.0", + name: "陈旧客户端版本", + originator: "codex_cli_rs", + userAgent: "codex_cli_rs/0.125.0", + version: "0.125.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := make(http.Header) - if tt.originator != "" { - h.Set("originator", tt.originator) - } + h.Set("originator", tt.originator) if tt.userAgent != "" { h.Set("user-agent", tt.userAgent) } @@ -134,15 +113,187 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { enforceCodexIdentityHeaders(h) - require.Equal(t, tt.wantOriginator, h.Get("originator")) - require.Equal(t, tt.wantUA, h.Get("user-agent")) - require.Equal(t, tt.wantVersion, h.Get("version")) + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) }) } } -// enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的 -// 调用方先显式调用 ensureCodexIdentityHeaders。 +// 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹, +// originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。 +func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { + t.Run("官方形态覆写 UA 保留指纹但重建版本段", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "luna/1.0.0") + h.Set("version", "2.1.0") + + enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode") + + require.Equal(t, "codex_vscode", h.Get("originator")) + require.Equal(t, "codex_vscode/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + }) + + t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0") + + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + }) + + // 回归:覆写 UA 填写于某个历史版本时,其版本段必须被重建而不是逐字沿用—— + // 否则这条配置会绕过版本自动同步,把出站身份永久钉死在陈旧版本上, + // 稳定落在上游优先降载的那一侧(UA 与 version 头也不再同源)。 + t.Run("陈旧覆写 UA 的版本段被重建", func(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color") + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) + require.NotContains(t, h.Get("user-agent"), "0.125.0") + }) + + // 陈旧覆写 UA 同样跟随自动同步到的新版本,无需管理员重新编辑那条 UA。 + t.Run("陈旧覆写 UA 跟随同步版本", func(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "codex-tui/0.125.0 (Mac OS X 14.0; arm64) iTerm") + + require.Equal(t, "codex-tui", h.Get("originator")) + require.Equal(t, "codex-tui/0.200.1 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent")) + require.Equal(t, "0.200.1", h.Get("version")) + }) +} + +// 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。 +// +// 不得给本用例加 t.Parallel():它改写进程级解析器。 +func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color" + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex_cli_rs", h.Get("originator")) + require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) + require.Equal(t, "0.200.1", h.Get("version")) +} + +// 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份, +// 绝不能把不可控内容拼进出站身份。 +// +// 不得给本用例加 t.Parallel():它改写进程级解析器。 +func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) +} + +// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」 +// 一侧,否则任意一处零值构造都会静默关掉全局保护。 +// +// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。 +func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) { + var cfg config.Config + require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement, + "零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护") + + SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) +} + +// 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套 +// (issue #3901),供上游策略变动时回滚。 +func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) { + const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)" + + SetCodexIdentityEnforcementEnabled(false) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", tuiUA) + h.Set("version", "0.145.2") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, "codex-tui", h.Get("originator")) + require.Equal(t, tuiUA, h.Get("user-agent")) + require.Equal(t, "0.145.2", h.Get("version")) +} + +// 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。 +func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) { + SetCodexIdentityEnforcementEnabled(false) + t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) }) + + h := make(http.Header) + h.Set("originator", "opencode") + h.Set("user-agent", "luna/1.0.0") + h.Set("version", "2.1.0") + + enforceCodexIdentityHeaders(h) + + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) + require.Equal(t, codexCLIUserAgent, h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) +} + +// 收口必须幂等:透传等路径可能先后多次经过收口。 +func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) { + h := make(http.Header) + h.Set("originator", "codex-tui") + h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + + enforceCodexIdentityHeaders(h) + firstUA, firstVersion := h.Get("user-agent"), h.Get("version") + enforceCodexIdentityHeaders(h) + + require.Equal(t, firstUA, h.Get("user-agent")) + require.Equal(t, firstVersion, h.Get("version")) + require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator")) +} + +// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除 +// originator,不应被补回身份头。 func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { h := make(http.Header) h.Set("user-agent", "third-party-client/1.0.0") @@ -150,5 +301,24 @@ func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) { enforceCodexIdentityHeaders(h) require.Empty(t, h.Get("originator")) + require.Empty(t, h.Get("version")) require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent")) } + +func TestNormalizeCodexClientVersion(t *testing.T) { + require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 ")) + require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4")) + require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2")) + require.Empty(t, NormalizeCodexClientVersion("")) + require.Empty(t, NormalizeCodexClientVersion("v0.146.0")) + require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)")) + require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1")) + require.Empty(t, NormalizeCodexClientVersion("latest")) +} + +func TestBuildCodexCLIUserAgent(t *testing.T) { + require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1")) + // 非法版本号必须回退到内置 UA,不能拼出畸形身份。 + require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version")) + require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("")) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 8349a0075c..c36c46eb13 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -18,6 +18,7 @@ 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" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -304,7 +305,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(headers, credAccount) } headers.Set("Accept", "application/json") - headers.Set("Originator", "codex_cli_rs") + headers.Set("Originator", openai.CodexDefaultOriginator) headers.Set("Version", clientVersion) headers.Set("User-Agent", codexCLIUserAgent) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 6333f16a71..585ad95d5e 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/stretchr/testify/require" "golang.org/x/net/http2" @@ -193,7 +194,7 @@ func TestFetchCodexModelsManifestPassthrough(t *testing.T) { if gotAccountID != "acc-123" { t.Errorf("chatgpt-account-id header: got %q", gotAccountID) } - if gotOriginator != "codex_cli_rs" { + if gotOriginator != openai.CodexDefaultOriginator { t.Errorf("originator header: got %q", gotOriginator) } if gotClientVersion != "0.137.0" { @@ -447,7 +448,7 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" { t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization")) } - if gotRequest.Header.Get("Originator") != "codex_cli_rs" { + if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator { t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator")) } if gotRequest.Header.Get("Version") != "0.144.0" { diff --git a/backend/internal/service/openai_codex_pat_service.go b/backend/internal/service/openai_codex_pat_service.go index e109265aba..dce6785920 100644 --- a/backend/internal/service/openai_codex_pat_service.go +++ b/backend/internal/service/openai_codex_pat_service.go @@ -10,6 +10,7 @@ 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" @@ -58,7 +59,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex } req.Header.Set("authorization", "Bearer "+accessToken) req.Header.Set("accept", "application/json") - req.Header.Set("originator", "codex_cli_rs") + req.Header.Set("originator", openai.CodexDefaultOriginator) req.Header.Set("user-agent", codexCLIUserAgent) resp, err := client.Do(req) diff --git a/backend/internal/service/openai_codex_pat_service_test.go b/backend/internal/service/openai_codex_pat_service_test.go index 24ac7a7c16..e7656868ba 100644 --- a/backend/internal/service/openai_codex_pat_service_test.go +++ b/backend/internal/service/openai_codex_pat_service_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) { info, err := svc.ValidateCodexPersonalAccessToken(context.Background(), " at-test-token ", "") require.NoError(t, err) require.Equal(t, "Bearer at-test-token", gotAuthorization) - require.Equal(t, "codex_cli_rs", gotOriginator) + require.Equal(t, openai.CodexDefaultOriginator, gotOriginator) require.Equal(t, codexCLIUserAgent, gotUserAgent) require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode) require.Equal(t, "user@example.com", info.Email) diff --git a/backend/internal/service/openai_codex_version_consistency_test.go b/backend/internal/service/openai_codex_version_consistency_test.go index 48883032aa..aede2ae432 100644 --- a/backend/internal/service/openai_codex_version_consistency_test.go +++ b/backend/internal/service/openai_codex_version_consistency_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/stretchr/testify/require" ) @@ -13,7 +14,7 @@ func TestCodexVersionConstants_Consistency(t *testing.T) { require.Equal(t, codexCLIVersion, openAICodexProbeVersion, "codexCLIVersion and openAICodexProbeVersion must stay in sync") - require.True(t, strings.Contains(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion), + require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion), "codexCLIUserAgent must embed codexCLIVersion") require.True(t, strings.Contains(DefaultOpenAICodexUserAgent, codexCLIVersion), diff --git a/backend/internal/service/openai_codex_version_sync_service.go b/backend/internal/service/openai_codex_version_sync_service.go new file mode 100644 index 0000000000..c42399cbec --- /dev/null +++ b/backend/internal/service/openai_codex_version_sync_service.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +const ( + // openAICodexVersionSyncInterval 自动同步间隔。上游客户端发版频率是天级, + // 6 小时足够及时跟上,同时把对 GitHub API 的调用压到每天 4 次。 + openAICodexVersionSyncInterval = 6 * time.Hour + // openAICodexVersionSyncTimeout 单次同步的整体超时。 + openAICodexVersionSyncTimeout = 30 * time.Second + // openAICodexVersionSyncRepo 官方 Codex 客户端仓库。 + openAICodexVersionSyncRepo = "openai/codex" + // openAICodexVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见 + // fetchLatestStableVersion)。该仓库预发布极密集——0.145.0 与 0.146.0 之间隔着 20 多个 + // alpha,实测 30 条里只有 2 条稳定版,第二条已排在第 26 位,因此这个页大小不能再往下调, + // 否则整页扫不到稳定版、同步会静默停更。 + openAICodexVersionSyncPerPage = 30 + // openAICodexVersionTagPrefix 客户端 release 的 tag 前缀(如 rust-v0.146.0)。 + // 同仓库还有其他组件的 tag(如 rusty-v8-*),必须按前缀过滤,否则会同步到无关版本号。 + openAICodexVersionTagPrefix = "rust-v" +) + +// OpenAICodexVersionSyncService 周期性把官方 Codex 客户端的最新稳定版版本号同步到设置, +// 供出站规范身份使用,避免为了跟上游版本而发新版本。 +// +// 同步值写入 SettingKeyOpenAICodexClientVersionSynced(本服务独占写入);管理员在面板填写的 +// SettingKeyOpenAICodexClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。 +type OpenAICodexVersionSyncService struct { + settingRepo SettingRepository + settingService *SettingService + githubClient GitHubReleaseClient + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewOpenAICodexVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, + interval time.Duration, +) *OpenAICodexVersionSyncService { + return &OpenAICodexVersionSyncService{ + settingRepo: settingRepo, + settingService: settingService, + githubClient: githubClient, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *OpenAICodexVersionSyncService) Start() { + if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runInitial() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *OpenAICodexVersionSyncService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过: +// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求, +// 而版本号是天级变化的,重启后没有立刻重新拉取的必要。 +func (s *OpenAICodexVersionSyncService) runInitial() { + if s.syncedWithinInterval() { + return + } + s.runOnce() +} + +// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。 +// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。 +// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。 +func (s *OpenAICodexVersionSyncService) syncedWithinInterval() bool { + if s.interval <= 0 { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout) + defer cancel() + + setting, err := s.settingRepo.Get(ctx, SettingKeyOpenAICodexClientVersionSynced) + if err != nil || setting == nil || setting.UpdatedAt.IsZero() { + return false + } + if NormalizeCodexClientVersion(setting.Value) == "" { + return false + } + return time.Since(setting.UpdatedAt) < s.interval +} + +func (s *OpenAICodexVersionSyncService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout) + defer cancel() + + if !s.autoSyncEnabled(ctx) { + return + } + + latest := s.fetchLatestStableVersion(ctx) + if latest == "" { + return + } + + current := NormalizeCodexClientVersion(s.currentSyncedVersion(ctx)) + // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。 + if current != "" && CompareVersions(latest, current) <= 0 { + return + } + if err := s.settingRepo.Set(ctx, SettingKeyOpenAICodexClientVersionSynced, latest); err != nil { + slog.Warn("openai_codex_version_sync_persist_failed", "version", latest, "error", err) + return + } + s.settingService.InvalidateOpenAICodexClientVersionCache() + slog.Info("openai_codex_version_synced", "previous", current, "version", latest) +} + +// fetchLatestStableVersion 取官方最新稳定版客户端版本号;取不到时返回空串, +// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。 +// +// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布, +// 因此不受该仓库预发布密度的影响,也不需要为了「窗口里得有一条稳定版」而多拉数据—— +// 实测单条 release 约 0.3MB,而 per_page=30 的列表页约 10MB。 +// +// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,若同仓库其他组件 +// (如 rusty-v8-*)某天发了正式 release 而成为 latest,主路径会被 rust-v 前缀过滤挡掉; +// 此时必须扫一页 release 才能继续跟随官方版本,否则版本号会静默停更。 +// 两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态),语义不会分叉。 +func (s *OpenAICodexVersionSyncService) fetchLatestStableVersion(ctx context.Context) string { + release, err := s.githubClient.FetchLatestRelease(ctx, openAICodexVersionSyncRepo) + if err != nil { + slog.Warn("openai_codex_version_sync_latest_fetch_failed", "error", err) + } else if version := latestCodexStableReleaseVersion([]*GitHubRelease{release}); version != "" { + return version + } + + // 主路径没拿到可用版本(抓取失败,或 latest 不是客户端 tag 家族的稳定版)。 + releases, err := s.githubClient.FetchRecentReleases(ctx, openAICodexVersionSyncRepo, openAICodexVersionSyncPerPage) + if err != nil { + slog.Warn("openai_codex_version_sync_fetch_failed", "error", err) + return "" + } + version := latestCodexStableReleaseVersion(releases) + if version == "" { + slog.Warn("openai_codex_version_sync_no_stable_release", "repo", openAICodexVersionSyncRepo) + } + return version +} + +// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致; +// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。 +func (s *OpenAICodexVersionSyncService) autoSyncEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexVersionAutoSyncEnabled) + if err != nil { + return true + } + if strings.TrimSpace(value) == "" { + return true + } + return strings.TrimSpace(value) == "true" +} + +func (s *OpenAICodexVersionSyncService) currentSyncedVersion(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexClientVersionSynced) + if err != nil { + return "" + } + return value +} + +// latestCodexStableReleaseVersion 从 release 列表里挑出最大的稳定版客户端版本号。 +// 过滤条件:tag 前缀为 rust-v(排除同仓库其他组件的 tag)、非草稿、非预发布、 +// 版本号不带 -alpha/-beta 之类后缀。取最大值而非最新发布,避免重新发布历史 tag 造成回退。 +// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。 +func latestCodexStableReleaseVersion(releases []*GitHubRelease) string { + best := "" + for _, release := range releases { + if release == nil || release.Draft || release.Prerelease { + continue + } + tag := strings.TrimSpace(release.TagName) + if !strings.HasPrefix(tag, openAICodexVersionTagPrefix) { + continue + } + version := NormalizeCodexClientVersion(strings.TrimPrefix(tag, openAICodexVersionTagPrefix)) + if version == "" || strings.Contains(version, "-") { + continue + } + if best == "" || CompareVersions(version, best) > 0 { + best = version + } + } + return best +} diff --git a/backend/internal/service/openai_codex_version_sync_service_test.go b/backend/internal/service/openai_codex_version_sync_service_test.go new file mode 100644 index 0000000000..2a50527b89 --- /dev/null +++ b/backend/internal/service/openai_codex_version_sync_service_test.go @@ -0,0 +1,480 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 --- + +type codexVersionSyncSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + mu sync.Mutex + values map[string]string + getErr error + setErr error + updatedAt time.Time + writes []string +} + +func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub { + if values == nil { + values = map[string]string{} + } + return &codexVersionSyncSettingRepoStub{values: values} +} + +func (r *codexVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return "", r.getErr + } + return r.values[key], nil +} + +func (r *codexVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.setErr != nil { + return r.setErr + } + r.values[key] = value + r.writes = append(r.writes, value) + return nil +} + +func (r *codexVersionSyncSettingRepoStub) syncedWrites() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.writes...) +} + +type codexVersionSyncGitHubStub struct { + GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用) + + // 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」, + // 使调用落到回退的列表扫描上。 + latest *GitHubRelease + latestErr error + latestCalls int + + releases []*GitHubRelease + err error + calls int +} + +func (c *codexVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) { + c.latestCalls++ + if c.latestErr != nil { + return nil, c.latestErr + } + return c.latest, nil +} + +func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return c.releases, nil +} + +func newCodexVersionSyncService( + repo SettingRepository, + github GitHubReleaseClient, +) *OpenAICodexVersionSyncService { + return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval) +} + +// 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版, +// 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。 +func TestLatestCodexStableReleaseVersion(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "rusty-v8-v150.4.0"}, + {TagName: "rust-v0.147.0-alpha.4", Prerelease: true}, + {TagName: "rust-v0.146.0"}, + {TagName: "rust-v0.145.0"}, + {TagName: "rust-v0.999.0", Draft: true}, + {TagName: "not-a-tag"}, + nil, + } + + require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases)) + require.Empty(t, latestCodexStableReleaseVersion(nil)) + require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}})) + // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。 + require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}})) +} + +func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{ + {TagName: "rust-v0.145.0"}, + {TagName: "rust-v0.146.0"}, + }} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) +} + +// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。 +func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites()) +} + +func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexVersionAutoSyncEnabled: "false", + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游") + require.Zero(t, github.calls, "关闭自动同步后不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +// 面板开关缺失或为空一律视为开启,与设置默认值一致。 +func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) { + for _, value := range []string{"", "true"} { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexVersionAutoSyncEnabled: value, + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value) + } +} + +// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。 +func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + github := &codexVersionSyncGitHubStub{ + latestErr: errors.New("network down"), + err: errors.New("network down"), + } + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Empty(t, repo.syncedWrites()) + value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced) + require.NoError(t, err) + require.Equal(t, "0.146.0", value) +} + +// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布, +// 因此不受该仓库预发布密度影响,也不必再去拉 ~10MB 的列表页。 +func TestOpenAICodexVersionSyncUsesLatestReleaseEndpoint(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + github := &codexVersionSyncGitHubStub{ + latest: &GitHubRelease{TagName: "rust-v0.146.0"}, + // 列表若被调用会给出不同答案,用于证明取值确实来自主路径。 + releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}, + } + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Zero(t, github.calls, "主路径可用时不应再拉列表页") + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) +} + +// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,主路径拿不到客户端稳定版时 +// 必须继续扫一页 release,否则版本号会静默停更。三种拿不到的形态都要回退。 +func TestOpenAICodexVersionSyncFallsBackToReleaseList(t *testing.T) { + tests := []struct { + name string + latest *GitHubRelease + latestErr error + }{ + // 同仓库其他组件(rusty-v8-*)某天发了正式 release 而成为 latest。 + {name: "latest 属于其他 tag 家族", latest: &GitHubRelease{TagName: "rusty-v8-v150.4.0"}}, + // 端点语义若变化(返回 draft/prerelease),过滤仍然拦得住,并照常回退。 + {name: "latest 是预发布", latest: &GitHubRelease{TagName: "rust-v0.147.0-alpha.4", Prerelease: true}}, + {name: "latest 抓取失败", latestErr: errors.New("network down")}, + // 上游返回空对象:不得因此 panic,按拿不到处理。 + {name: "latest 为空", latest: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + github := &codexVersionSyncGitHubStub{ + latest: tt.latest, + latestErr: tt.latestErr, + releases: []*GitHubRelease{ + {TagName: "rust-v0.147.0-alpha.4", Prerelease: true}, + {TagName: "rust-v0.146.0"}, + {TagName: "rust-v0.145.0"}, + }, + } + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) + }) + } +} + +// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验, +// 不能因为「端点保证是正式发布」就直接采信 tag。 +func TestOpenAICodexVersionSyncLatestSharesFiltering(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + github := &codexVersionSyncGitHubStub{ + // 仓库里确实存在 rust-vv0.99.0-alpha.8 / rust-vrust-v0.145.0-alpha.6 这类畸形 tag: + // 剥掉前缀后不是合法版本号,必须被拒绝而不是写成 v0.99.0。 + latest: &GitHubRelease{TagName: "rust-vv0.99.0"}, + releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}, + } + + newCodexVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) +} + +// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。 +func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) { + require.NotPanics(t, func() { + svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval) + svc.Start() + svc.Stop() + }) +} + +// --- mock: 版本号读取只用到 GetMultiple --- + +type codexVersionSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + values map[string]string + err error +} + +func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + if r.err != nil { + return nil, r.err + } + out := make(map[string]string, len(keys)) + for _, key := range keys { + out[key] = r.values[key] + } + return out, nil +} + +// 规范 UA 解析会先读面板的完整 UA 键。 +func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + if r.err != nil { + return "", r.err + } + return r.values[key], nil +} + +// 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。 +// 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。 +func TestGetOpenAICodexClientVersionPriority(t *testing.T) { + tests := []struct { + name string + override string + synced string + want string + }{ + {name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"}, + {name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"}, + {name: "两者皆空时用内置常量", want: codexCLIVersion}, + {name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"}, + {name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexClientVersion: tt.override, + SettingKeyOpenAICodexClientVersionSynced: tt.synced, + }}, nil) + + require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background())) + }) + } +} + +// 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。 +func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil) + require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background())) +} + +// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 TUI 形态。 +func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex-tui/0.200.1"+codexCLIUserAgentSuffix, + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) +} + +// 回归:面板完整 UA 是唯一能改 OS / 架构 / 终端指纹的地方,必须保留;但它填写于某个 +// 历史版本,逐字沿用会绕过版本自动同步、把出站身份永久钉死在陈旧版本上——而陈旧身份 +// 正是上游优先降载的那一侧。因此只借它的指纹,版本段一律用生效版本重建。 +func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) { + t.Run("陈旧面板 UA 跟随生效版本", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + // 历史面板 placeholder 的原文,照抄填写过的存量部署就是这个值。 + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + t.Run("自定义指纹原样保留", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.140.0 (Mac OS X 15.1.0; arm64) iTerm.app", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + t.Run("TUI UA 的首尾两个版本号同时更新", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + // 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。 + t.Run("面板版本号覆写优先", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + SettingKeyOpenAICodexClientVersion: "0.150.0", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + // 非 `{client}/{version}` 形态无法重建,原样返回,由收口整体回退规范身份。 + t.Run("非 Codex 形态原样返回", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "not-a-codex-client", + }}, nil) + + require.Equal(t, "not-a-codex-client", svc.GetOpenAICodexCanonicalUserAgent(context.Background())) + }) +} + +func (r *codexVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return nil, r.getErr + } + value, ok := r.values[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil +} + +// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步 +// 放大成对 GitHub 的连续请求。 +func TestOpenAICodexVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + repo.updatedAt = time.Now().Add(-time.Hour) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}} + + newCodexVersionSyncService(repo, github).runInitial() + + require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +func TestOpenAICodexVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) { + t.Run("同步值已过期", func(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + repo.updatedAt = time.Now().Add(-7 * time.Hour) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}} + + newCodexVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"0.147.0"}, repo.syncedWrites()) + }) + + // 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。 + t.Run("尚无同步值", func(t *testing.T) { + repo := newCodexVersionSyncSettingRepoStub(nil) + repo.updatedAt = time.Now() + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}} + + newCodexVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"0.146.0"}, repo.syncedWrites()) + }) +} + +// 版本比较必须按段取数字:字典序会把 0.99.0 判为大于 0.146.0, +// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。 +func TestCodexVersionComparisonIsNumericNotLexical(t *testing.T) { + require.Greater(t, CompareVersions("0.146.0", "0.99.0"), 0) + + require.Equal(t, "0.146.0", latestCodexStableReleaseVersion([]*GitHubRelease{ + {TagName: "rust-v0.99.0"}, + {TagName: "rust-v0.146.0"}, + })) + + repo := newCodexVersionSyncSettingRepoStub(map[string]string{ + SettingKeyOpenAICodexClientVersionSynced: "0.146.0", + }) + github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.99.0"}}} + + newCodexVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites(), "0.99.0 低于已同步的 0.146.0,不得写入") +} diff --git a/backend/internal/service/openai_compact_service_tier_test.go b/backend/internal/service/openai_compact_service_tier_test.go new file mode 100644 index 0000000000..7e92d8d2c2 --- /dev/null +++ b/backend/internal/service/openai_compact_service_tier_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestNormalizeOpenAICompactRequestBodyPreservesServiceTier(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.6-sol", + "input":[{"type":"message","role":"user","content":"hello"}], + "service_tier":"priority", + "prompt_cache_key":"compact-cache-key", + "store":false, + "stream":true + }`) + + normalized, changed, err := normalizeOpenAICompactRequestBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(normalized, "model").String()) + require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String()) + require.False(t, gjson.GetBytes(normalized, "prompt_cache_key").Exists()) + require.False(t, gjson.GetBytes(normalized, "store").Exists()) + require.False(t, gjson.GetBytes(normalized, "stream").Exists()) +} + +func TestOpenAIOAuthCompactHTTPBuildersUsePreservedServiceTierInRoutingHint(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{ + "model":"gpt-5.6-sol", + "input":[{"type":"message","role":"user","content":"hello"}], + "service_tier":"priority", + "stream":true + }`) + normalized, changed, err := normalizeOpenAICompactRequestBody(body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String()) + + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "test-account", + }, + } + svc := &OpenAIGatewayService{} + + tests := []struct { + name string + build func(*gin.Context) (*http.Request, error) + }{ + { + name: "ordinary", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequest( + context.Background(), c, account, normalized, "test-token", + false, "", true, + ) + }, + }, + { + name: "passthrough", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequestOpenAIPassthrough( + context.Background(), c, account, normalized, "test-token", + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest( + http.MethodPost, + "/v1/responses/compact", + bytes.NewReader(normalized), + ) + + req, buildErr := tt.build(c) + require.NoError(t, buildErr) + require.Equal( + t, + "model=gpt-5.6-sol;tier=priority", + req.Header.Get(openAICodexRoutingHintHeader), + ) + require.Equal(t, "priority", gjson.GetBytes(normalized, "service_tier").String()) + }) + } +} diff --git a/backend/internal/service/openai_compact_sse_keepalive_test.go b/backend/internal/service/openai_compact_sse_keepalive_test.go index 1efed7e9e4..95ee4ce76c 100644 --- a/backend/internal/service/openai_compact_sse_keepalive_test.go +++ b/backend/internal/service/openai_compact_sse_keepalive_test.go @@ -284,6 +284,20 @@ func TestOpenAICompactKeepaliveAdjustedWrittenSize_ExcludesHeartbeatBytes(t *tes require.Contains(t, rec.Body.String(), ": keepalive\n\n") } +func TestOpenAIStreamClientOutputStarted_IgnoresCompactKeepaliveBytes(t *testing.T) { + c, _ := newCompactBridgeTestContext(t, true) + stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval) + defer stop() + waitForKeepaliveBeats() + + require.True(t, c.Writer.Written()) + require.False(t, openAIStreamClientOutputStarted(c, false), "keepalive comments are not semantic output") + + _, err := c.Writer.Write([]byte("real-output")) + require.NoError(t, err) + require.True(t, openAIStreamClientOutputStarted(c, false)) +} + // fast policy block 在心跳未提交时保持 403 JSON 原语义。 func TestWriteOpenAIFastPolicyBlockedResponse_BeforeKeepaliveCommit(t *testing.T) { c, rec := newCompactBridgeTestContext(t, true) diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 68313e2b36..7ccfbdeadd 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -18,6 +18,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -997,7 +998,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, firstResult) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) secondRec := httptest.NewRecorder() @@ -1011,7 +1012,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { 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.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1020,27 +1021,16 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { gin.SetMode(gin.TestMode) const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)" + const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)" + // messages 桥接路径同样强制统一出站身份:三类客户端身份都收敛到规范身份。 tests := []struct { - name string - userAgent string - originator string - wantUserAgent string - wantOriginator string + name string + userAgent string + originator string }{ - { - name: "官方UA逐字保留并重新配对", - userAgent: tuiUA, - originator: "opencode", - wantUserAgent: tuiUA, - wantOriginator: "codex-tui", - }, - { - name: "第三方UA回退为默认Codex身份", - userAgent: "third-party-client/1.0.0", - originator: "opencode", - wantUserAgent: codexCLIUserAgent, - wantOriginator: "codex_cli_rs", - }, + {name: "官方vscode身份", userAgent: vscodeUA, originator: "opencode"}, + {name: "TUI身份", userAgent: tuiUA, originator: "opencode"}, + {name: "第三方UA", userAgent: "third-party-client/1.0.0", originator: "opencode"}, } for _, tt := range tests { @@ -1073,7 +1063,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, result) - requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, tt.wantUserAgent, tt.wantOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, openai.CodexDefaultOriginator) }) } } @@ -1116,7 +1106,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey firstSessionID := upstream.requests[0].Header.Get("session_id") require.NotEmpty(t, firstSessionID) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) @@ -1131,7 +1121,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1286,7 +1276,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) { instructions := gjson.GetBytes(upstream.lastBody, "instructions") require.True(t, instructions.Exists()) require.Empty(t, instructions.String()) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator) } func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) { diff --git a/backend/internal/service/openai_cyber_session_block_test.go b/backend/internal/service/openai_cyber_session_block_test.go index 3957b824e5..6d6ad8bad4 100644 --- a/backend/internal/service/openai_cyber_session_block_test.go +++ b/backend/internal/service/openai_cyber_session_block_test.go @@ -133,6 +133,21 @@ func (c *comboCacheAndStore) RefreshSessionTTL(_ context.Context, _ int64, _ str func (c *comboCacheAndStore) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error { return nil } + +func (c *comboCacheAndStore) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (c *comboCacheAndStore) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (c *comboCacheAndStore) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error { return c.store.SetCyberSessionBlocked(ctx, key, ttl) } 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 b7af70ec75..80865e2891 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -63,6 +63,72 @@ func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *tes require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String()) } +// TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs +// verifies that reasoning items with a non-rs id (e.g. item_*) are stripped +// before forwarding. OpenAI upstream requires reasoning ids to begin with +// "rs" and rejects item_* with 400: +// "Expected an ID that begins with 'rs'." (#5410) +func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`, + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":false, + "input":[ + {"type":"reasoning","id":"item_bad_reasoning","summary":[]}, + {"type":"reasoning","id":"rs_valid","summary":[]}, + {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]} + ] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + + forwarded := upstream.lastBody + require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists(), + "item_* id should be stripped from reasoning") + require.Equal(t, "rs_valid", gjson.GetBytes(forwarded, "input.1.id").String(), + "valid rs* id must be preserved") + require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String()) +} + +func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) { + cases := []struct { + name string + itemType string + id string + want bool + }{ + {"reasoning item_* id", "reasoning", "item_bad_reasoning", true}, + {"reasoning rs id", "reasoning", "rs_abc123", false}, + {"reasoning empty id", "reasoning", "", false}, + {"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 item id", "function_call", "item_x", true}, + {"unconstrained type", "web_search_call", "ws_001", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, shouldStripOpenAIResponsesInputItemID(tc.itemType, tc.id)) + }) + } +} + func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) { makeBody := func(itemCount int) []byte { items := make([]string, itemCount) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 635b985ea0..8080d49e5c 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -89,6 +89,11 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( upstreamModel string, ) *UpstreamFailoverError { shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) + tempUnscheduled := false + if c != nil && account != nil && account.Platform != PlatformGrok && !shouldFailover && !IsResponseCommitted(c) && s.rateLimitService != nil { + tempUnscheduled = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, upstreamModel) == ErrorPolicyTempUnscheduled + shouldFailover = tempUnscheduled + } if account != nil && account.Platform == PlatformGrok { shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) } @@ -116,8 +121,8 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( Message: upstreamMsg, Detail: upstreamDetail, }) - shouldDisable := false - if account.Platform != PlatformGrok { + shouldDisable := tempUnscheduled + if account.Platform != PlatformGrok && !tempUnscheduled { shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } return newOpenAIUpstreamFailoverError( diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 8a8349b5f8..87973304c0 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -58,6 +58,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + beginUpstreamResponseModelObservation(c) + restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) if restrictionResult.Enabled && !restrictionResult.Matched { @@ -419,6 +421,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event") return nil, fmt.Errorf("upstream stream ended without terminal event") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.Observe(finalResponse.Model, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) // cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4), @@ -441,7 +448,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( } message := openAICompatFailedResponseMessage(finalResponse) if openAIStreamFailedEventShouldFailover(payload, message) { - return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message) + return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, resp.Header) } message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message) // response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码;统一走语义 @@ -460,6 +467,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( return nil, fmt.Errorf("upstream response failed: %s", message) } + if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, finalResponse.Model) && !hasBillableGrokChatUsage(usage) { + upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) + return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) + } + // When the terminal event has an empty output array, reconstruct from // accumulated delta events so the client receives the full content. acc.SupplementResponseOutput(finalResponse) @@ -476,15 +488,26 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") c.JSON(http.StatusOK, chatResp) - return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), - }, nil + result := &OpenAIForwardResult{ + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: false, + Duration: time.Since(startTime), + } + // Grok chat bridge: bill native search tools found in the terminal Responses body. + if account != nil && account.IsGrok() && finalResponse != nil { + if body, err := json.Marshal(finalResponse); err == nil { + if n := countGrokNativeSearchCallsFromJSONBytes(body); n > 0 { + result.SearchCount = n + } + } + } + return result, nil } // handleChatStreamingResponse reads Responses SSE events from upstream, @@ -517,6 +540,14 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen) var streamFailoverErr *UpstreamFailoverError var streamNonFailoverErr error + // Grok chat bridge reuses Responses SSE; count native search tools for surcharge. + searchCount := 0 + streamSearchSeen := make(map[string]struct{}) + countSearch := account != nil && account.IsGrok() + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } scanner := s.newUpstreamSSEScanner(resp.Body) @@ -535,16 +566,22 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } resultWithUsage := func() *OpenAIForwardResult { - return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + out := &OpenAIForwardResult{ + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } + if searchCount > 0 { + out.SearchCount = searchCount + } + return out } processDataLine := func(payload string) bool { @@ -553,6 +590,9 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } + if countSearch { + searchCount += countGrokNativeSearchCallsInSSEDataDedup([]byte(payload), streamSearchSeen) + } var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { @@ -562,6 +602,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( ) return false } + observer.ObserveOpenAI([]byte(payload), event.Type) refusalDetector.ObservePayload([]byte(payload)) isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type) @@ -608,7 +649,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( return true } if openAIStreamFailedEventShouldFailover(payloadBytes, message) { - streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message) + streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header) return true } message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message) diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 49cd591829..010cd4df2a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -144,6 +144,10 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, fmt.Errorf("remove Responses-only Grok prompt cache key: %w", err) } + upstreamBody, err = normalizeGrokChatReasoningEffort(upstreamBody, upstreamModel) + if err != nil { + return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err) + } } logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", @@ -214,7 +218,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if clientStream { result, forwardErr = s.streamRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, len(body)) } else { - result, forwardErr = s.bufferRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + result, forwardErr = s.bufferRawChatCompletions(c, resp, account, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } if result != nil { addOpenAIUsage(&result.Usage, bridgeUsage) @@ -225,7 +229,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( func (s *OpenAIGatewayService) rawChatCompletionsURL(account *Account) (string, error) { if account.Platform == PlatformGrok { - targetURL, err := buildGrokChatCompletionsURL(account, s.cfg) + targetURL, err := buildGrokChatCompletionsURL(account, s.cfg, s.settingService) if err != nil { return "", fmt.Errorf("invalid grok base_url: %w", err) } @@ -253,6 +257,10 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( startTime time.Time, requestBodyLen int, ) (*OpenAIForwardResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } requestID := resp.Header.Get("x-request-id") writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header) scanner := s.newUpstreamSSEScanner(resp.Body) @@ -302,6 +310,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( if payload, ok := extractOpenAISSEDataLine(line); ok { trimmedPayload := strings.TrimSpace(payload) if trimmedPayload != "[DONE]" { + observer.ObserveOpenAI([]byte(payload), strings.TrimSpace(gjson.Get(payload, "type").String())) usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload) if u := extractCCStreamUsage(payload); u != nil { usage = *u @@ -356,16 +365,18 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ReasoningEffort: reasoningEffort, + ServiceTier: serviceTier, + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } @@ -409,6 +420,7 @@ func extractCCStreamUsage(payload string) *OpenAIUsage { func (s *OpenAIGatewayService) bufferRawChatCompletions( c *gin.Context, resp *http.Response, + account *Account, originalModel string, billingModel string, upstreamModel string, @@ -425,11 +437,21 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( } return nil, fmt.Errorf("read upstream body: %w", err) } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.ObserveOpenAI(respBody, strings.TrimSpace(gjson.GetBytes(respBody, "type").String())) var usage OpenAIUsage if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { usage = parsedUsage } + responseModel := gjson.GetBytes(respBody, "model").String() + if requiresBillableGrokChatUsage(account, billingModel, upstreamModel, responseModel) && !hasBillableGrokChatUsage(usage) { + upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) + return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) + } if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -443,15 +465,17 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( _, _ = c.Writer.Write(respBody) return &OpenAIForwardResult{ - RequestID: requestID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, - Stream: false, - Duration: time.Since(startTime), + RequestID: requestID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ReasoningEffort: reasoningEffort, + ServiceTier: 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 61663e636a..a80234913a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -122,6 +123,127 @@ func TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDown require.Contains(t, rec.Body.String(), "data: [DONE]") } +func TestForwardAsChatCompletions_OpenAICompatibleGrokRawMissingUsageFailsBeforeWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"grok-4.5","messages":[{"role":"user","content":"hello"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"rid-openai-compat-grok-no-usage"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_missing_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + account.Name = "openai-compatible-grok" + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned by an OpenAI-compatible account") + require.Empty(t, recorder.Body.String()) +} + +func TestForwardAsChatCompletions_OpenAICompatibleRawUsageGuard(t *testing.T) { + tests := []struct { + name string + model string + upstreamResponse string + modelMapping map[string]any + wantGuarded bool + }{ + { + name: "Grok response without usage", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_missing","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: true, + }, + { + name: "namespaced Grok response without usage", + model: "x-ai/grok-4.5", + upstreamResponse: `{"id":"resp_namespaced","object":"chat.completion","model":"x-ai/grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: true, + }, + { + name: "Grok response with aggregate usage passes", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}`, + wantGuarded: false, + }, + { + name: "Grok alias mapped to non-Grok remains unchanged", + model: "grok-alias", + upstreamResponse: `{"id":"resp_mapped","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + modelMapping: map[string]any{"grok-alias": "gpt-5.4"}, + wantGuarded: false, + }, + { + name: "Grok response with detail-only usage", + model: "grok-4.5", + upstreamResponse: `{"id":"resp_detail_only","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"input_tokens_details":{"text_tokens":9,"image_tokens":2},"output_tokens_details":{"image_tokens":1}}}`, + wantGuarded: true, + }, + { + name: "non-Grok response without usage remains unchanged", + model: "gpt-5.4", + upstreamResponse: `{"id":"resp_openai","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`, + wantGuarded: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"` + tt.model + `","messages":[{"role":"user","content":"hello"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"rid-openai-compatible"}}, + Body: io.NopCloser(strings.NewReader(tt.upstreamResponse)), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + account.Name = "openai-compatible" + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + if tt.modelMapping != nil { + account.Credentials["model_mapping"] = tt.modelMapping + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + if !tt.wantGuarded { + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, c.Writer.Written()) + return + } + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, "grok_missing_usage", gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.False(t, c.Writer.Written(), "unbilled Grok content must not be returned") + }) + } +} + func TestForwardAsRawChatCompletions_PreservesMappedGPT56MaxEffort(t *testing.T) { gin.SetMode(gin.TestMode) @@ -638,7 +760,7 @@ func TestBufferRawChatCompletions_RejectsOversizedResponse(t *testing.T) { svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()} svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 3 - result, err := svc.bufferRawChatCompletions(c, resp, "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now()) + result, err := svc.bufferRawChatCompletions(c, resp, rawChatCompletionsTestAccount(), "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now()) require.ErrorIs(t, err, ErrUpstreamResponseBodyTooLarge) require.Nil(t, result) require.Equal(t, http.StatusBadGateway, rec.Code) diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index eccd1c4146..e56b33df45 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -345,12 +345,26 @@ func isOpenAIOAuthInputTokensUnsupported(statusCode int, body []byte) bool { return true } + // OAuth's platform endpoint can be blocked by an upstream proxy before it + // reaches the API and return an HTML 403 page without a structured error. + // Treat that endpoint-level response like the other unsupported cases so + // count_tokens remains a local, non-health-affecting convenience request. + if statusCode == http.StatusForbidden && isHTMLResponse(body) { + return true + } + return strings.Contains(msg, "input_tokens") && (strings.Contains(msg, "not found") || strings.Contains(msg, "not supported") || strings.Contains(msg, "unsupported")) } +func isHTMLResponse(body []byte) bool { + trimmed := strings.TrimSpace(strings.ToLower(string(body))) + return strings.HasPrefix(trimmed, "Forbidden", + }, { name: "404_input_tokens_unsupported", statusCode: http.StatusNotFound, @@ -306,6 +311,10 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { if apiKey == "" { t.Skip("OPENAI_API_KEY not set") } + // Invalid/expired keys in local env must not fail the unit suite. + if strings.HasPrefix(apiKey, "sk-") && len(apiKey) < 20 { + t.Skip("OPENAI_API_KEY looks incomplete") + } client := &http.Client{Timeout: 30 * time.Second} cases := []struct { @@ -340,7 +349,13 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { require.NoError(t, err) actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request) - require.NoError(t, err) + if err != nil { + // Live-API comparison only; invalid/expired local keys should skip, not fail CI. + if strings.Contains(err.Error(), "status=401") || strings.Contains(err.Error(), "invalid_api_key") { + t.Skipf("OPENAI_API_KEY rejected by OpenAI: %v", err) + } + require.NoError(t, err) + } diff := estimated - actual if diff < 0 { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b3963535b1..34c92dfe89 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -19,7 +19,9 @@ 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) clearGrokResponsesClientToolMapping(c) + clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body @@ -45,6 +47,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } + // 在分流到 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 { + body = sanitizedToolBody + } if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) if liteErr != nil { @@ -62,7 +74,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c)) passthroughEnabled := account.IsOpenAIPassthroughEnabled() - if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) { + compactPath := isOpenAIResponsesCompactPath(c) + if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) { body, err = flattenOpenAIResponsesNamespaces(c, body) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") @@ -73,7 +86,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) { - body, err = stripOpenAIResponsesInputNamespaces(body) + keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces( + account, wsDecision.Transport, passthroughEnabled, compactPath, + ) + body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ @@ -273,7 +289,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markPatchSet("model", billingModel) } upstreamModel := billingModel - isCompactRequest := isOpenAIResponsesCompactPath(c) + isCompactRequest := compactPath compactMapped := false if isCompactRequest { compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) @@ -923,6 +939,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco var firstTokenMs *int responseID := "" imageCount := 0 + searchCount := 0 var imageOutputSizes []string if reqStream { streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue) @@ -934,6 +951,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco responseID = strings.TrimSpace(streamResult.responseID) imageCount = streamResult.imageCount imageOutputSizes = streamResult.imageOutputSizes + searchCount = streamResult.searchCount } else { nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { @@ -943,6 +961,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco responseID = strings.TrimSpace(nonStreamResult.responseID) imageCount = nonStreamResult.imageCount imageOutputSizes = nonStreamResult.imageOutputSizes + searchCount = nonStreamResult.searchCount } s.bindHTTPResponseAccount(ctx, c, account, responseID) @@ -959,18 +978,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } forwardResult := &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - ResponseID: responseID, - Usage: *usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - ServiceTier: serviceTier, - ReasoningEffort: reasoningEffort, - Stream: reqStream, - OpenAIWSMode: false, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + ResponseID: responseID, + Usage: *usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ServiceTier: serviceTier, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + OpenAIWSMode: false, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } if imageCount > 0 { forwardResult.ImageCount = imageCount @@ -979,6 +1000,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco forwardResult.ImageOutputSizes = imageOutputSizes forwardResult.BillingModel = imageBillingModel } + // Grok-native web_search / x_search / tool_search tool invocations (per-1k pricing). + // Token cost still applies separately when usage is present; search is additive only + // when search_price_per_1k is configured (nil price → $0 from CalculateSearchCost). + if searchCount > 0 && account != nil && account.IsGrok() { + forwardResult.SearchCount = searchCount + } return forwardResult, nil } } @@ -1054,7 +1081,6 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Del("OpenAI-Beta") req.Header.Del("originator") } else { - req.Header.Set("OpenAI-Beta", "responses=experimental") req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } apiKeyID := getAPIKeyIDFromContext(c) @@ -1093,13 +1119,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", codexCLIUserAgent) } - // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 - // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 - s.overrideBrowserUserAgent(ctx, account, req) - - // 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。 + // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。 + // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } // Ensure required headers exist @@ -1109,30 +1132,18 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + setOpenAICodexRoutingHintFromBody(req.Header, account, body) + logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable") return req, nil } -// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。 -// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。 -// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。 -// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。 -func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) { - if req == nil || account == nil { - return +// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。 +// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串; +// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。 +func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string { + if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { + return "" } - if account.Type != AccountTypeOAuth { - return - } - currentUA := req.Header.Get("user-agent") - if !openai.IsBrowserUserAgent(currentUA) { - return - } - codexUA := DefaultOpenAICodexUserAgent - if s != nil && s.settingService != nil { - if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" { - codexUA = v - } - } - req.Header.Set("user-agent", codexUA) + return account.GetOpenAIUserAgent() } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index fdfd0bf321..3a737f72d5 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -22,14 +22,14 @@ import ( const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 - grokUpstreamUserAgent = "sub2api-grok/1.0" - grokCLIVersion = "0.2.93" - grokDefaultResponsesModel = "grok-4.5" - grokRateLimitFallbackCooldown = 2 * time.Minute - grokRateLimitRepeatCooldown = 10 * time.Minute - grokRateLimitSustainedCooldown = 30 * time.Minute - grokRateLimitMaxAdaptiveCooldown = time.Hour - grokRateLimitBackoffQuietPeriod = time.Hour + // 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 ) func (s *OpenAIGatewayService) forwardGrokResponses( @@ -49,6 +49,10 @@ func (s *OpenAIGatewayService) forwardGrokResponses( if strings.TrimSpace(upstreamModel) == "" { upstreamModel = grokDefaultResponsesModel } + // Account mappings are optional. Canonicalize client aliases even when the + // account has no model_mapping, matching the Chat Completions path and xAI's + // actual Responses model IDs. + upstreamModel = xai.ResolveGrokTextResponsesModelID(upstreamModel, grokDefaultResponsesModel) if isGrokImageGenerationModel(upstreamModel) { return nil, fmt.Errorf("model %s is an image model and is not available on the Responses endpoint; use /v1/images/generations instead", upstreamModel) } @@ -101,7 +105,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( upstreamStart := time.Now() var resp *http.Response for attempt := 0; ; attempt++ { - upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg) + upstreamReq, buildErr := buildGrokResponsesRequest(upstreamCtx, c, account, patchedBody, token, cacheIdentity, s.cfg, s.settingService) if buildErr != nil { return nil, buildErr } @@ -161,7 +165,13 @@ func (s *OpenAIGatewayService) forwardGrokResponses( Kind: kind, Message: upstreamMsg, }) - s.handleGrokAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + 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 { + markGrokTeamModelRateLimit(account, upstreamModel, resolveGrokTeamRateLimitUntil(time.Now().Add(grokTeamRateLimitDefaultTTL), time.Now())) + } if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, @@ -173,17 +183,23 @@ func (s *OpenAIGatewayService) forwardGrokResponses( return s.handleErrorResponse(ctx, resp, c, account, patchedBody, upstreamModel) } - s.updateGrokUsageFromResponse(ctx, account, resp.Header, resp.StatusCode) + // Attach model so rate-limit snapshots can fan out a team+model cool. + stateCtx := withGrokTeamRateLimitModel(ctx, upstreamModel) + s.updateGrokUsageFromResponse(stateCtx, account, resp.Header, resp.StatusCode) var usage *OpenAIUsage var firstTokenMs *int responseID := "" + searchCount := 0 + imageCount := 0 + var imageOutputSizes []string if reqStream { + maxLineSize := defaultMaxLineSize + if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { + maxLineSize = s.cfg.Gateway.MaxLineSize + } + resp.Body = newGrokResponsesBillingPingFilterBody(resp.Body, account, maxLineSize) if hasGrokResponsesClientToolMapping(clientToolMapping) { - maxLineSize := defaultMaxLineSize - if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 { - maxLineSize = s.cfg.Gateway.MaxLineSize - } resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize) } streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) @@ -193,6 +209,9 @@ func (s *OpenAIGatewayService) forwardGrokResponses( usage = streamResult.usage firstTokenMs = streamResult.firstTokenMs responseID = strings.TrimSpace(streamResult.responseID) + searchCount = streamResult.searchCount + imageCount = streamResult.imageCount + imageOutputSizes = streamResult.imageOutputSizes } else { nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { @@ -200,13 +219,16 @@ func (s *OpenAIGatewayService) forwardGrokResponses( } usage = nonStreamResult.usage responseID = strings.TrimSpace(nonStreamResult.responseID) + searchCount = nonStreamResult.searchCount + imageCount = nonStreamResult.imageCount + imageOutputSizes = nonStreamResult.imageOutputSizes } if usage == nil { usage = &OpenAIUsage{} } reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel) - return &OpenAIForwardResult{ + result := &OpenAIForwardResult{ RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")), ResponseID: responseID, Usage: *usage, @@ -218,7 +240,17 @@ func (s *OpenAIGatewayService) forwardGrokResponses( ResponseHeaders: resp.Header.Clone(), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, - }, nil + } + // Propagate search/image counters from the shared Responses handler — without + // this, stream/JSON counting runs but search_price_per_1k / image bills never apply. + if searchCount > 0 { + result.SearchCount = searchCount + } + if imageCount > 0 { + result.ImageCount = imageCount + result.ImageOutputSizes = imageOutputSizes + } + return result, nil } func isGrokInvalidEncryptedContentResponse(statusCode int, body []byte) bool { @@ -409,7 +441,13 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro if !json.Valid(body) { return nil, fmt.Errorf("invalid json request body") } - out, err := sjson.SetBytes(body, "model", upstreamModel) + // sjson may reuse the input backing array; keep the caller's request bytes + // unchanged because the same body can be inspected for billing/retry paths. + out, err := sjson.SetBytes(append([]byte(nil), body...), "model", upstreamModel) + if err != nil { + return nil, err + } + out, err = normalizeGrokResponsesReasoningEffort(out, upstreamModel) if err != nil { return nil, err } @@ -435,6 +473,16 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro } } } + if grokModelRejectsLogprobs(upstreamModel) { + for _, unsupportedField := range []string{"logprobs", "top_logprobs"} { + if gjson.GetBytes(out, unsupportedField).Exists() { + out, err = sjson.DeleteBytes(out, unsupportedField) + if err != nil { + return nil, err + } + } + } + } out, err = sanitizeGrokResponsesUnsupportedFields(out) if err != nil { return nil, err @@ -458,6 +506,17 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro return out, nil } +// xAI's Grok 4.20 family and newer models do not support OpenAI's logprobs +// fields. Remove them before egress instead of forwarding a request the +// upstream rejects. Older Grok models retain the fields for compatibility. +func grokModelRejectsLogprobs(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + if slash := strings.LastIndex(model, "/"); slash >= 0 { + model = strings.TrimSpace(model[slash+1:]) + } + return strings.HasPrefix(model, "grok-4.20") +} + func sanitizeGrokResponsesModelCapabilities(body []byte, upstreamModel string) ([]byte, error) { if !grokModelRejectsReasoningEffort(upstreamModel) { return body, nil @@ -490,6 +549,98 @@ func grokModelRejectsReasoningEffort(model string) bool { } } +func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([]byte, error) { + supportsEffort := grokSupportsReasoningEffort(upstreamModel) + out := body + var err error + for _, field := range []string{"reasoning.effort", "reasoning_effort"} { + value := gjson.GetBytes(out, field) + if !value.Exists() { + continue + } + normalized, keep := normalizeGrokReasoningEffortValue(value.String()) + if !supportsEffort || !keep { + out, err = sjson.DeleteBytes(out, field) + } else { + out, err = sjson.SetBytes(out, field, normalized) + } + if err != nil { + return nil, fmt.Errorf("normalize Grok reasoning field %s: %w", field, err) + } + } + if camel := gjson.GetBytes(out, "reasoningEffort"); camel.Exists() { + normalized, keep := normalizeGrokReasoningEffortValue(camel.String()) + out, err = sjson.DeleteBytes(out, "reasoningEffort") + if err != nil { + return nil, fmt.Errorf("remove Grok reasoningEffort: %w", err) + } + if supportsEffort && keep && !gjson.GetBytes(out, "reasoning_effort").Exists() { + out, err = sjson.SetBytes(out, "reasoning_effort", normalized) + if err != nil { + return nil, fmt.Errorf("set Grok reasoning_effort: %w", err) + } + } + } + if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 { + out, err = sjson.DeleteBytes(out, "reasoning") + if err != nil { + return nil, fmt.Errorf("remove empty Grok reasoning: %w", err) + } + } + return out, nil +} + +func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte, error) { + raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) + if raw == "" { + raw = strings.TrimSpace(gjson.GetBytes(body, "reasoningEffort").String()) + } + normalized, keep := normalizeGrokReasoningEffortValue(raw) + keep = keep && grokSupportsReasoningEffort(upstreamModel) + out := body + var err error + if gjson.GetBytes(out, "reasoningEffort").Exists() { + out, err = sjson.DeleteBytes(out, "reasoningEffort") + if err != nil { + return nil, err + } + } + if !keep { + if gjson.GetBytes(out, "reasoning_effort").Exists() { + out, err = sjson.DeleteBytes(out, "reasoning_effort") + } + return out, err + } + out, err = sjson.SetBytes(out, "reasoning_effort", normalized) + return out, err +} + +func normalizeGrokReasoningEffortValue(raw 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": + return "high", true + default: + return "", false + } +} + +func grokSupportsReasoningEffort(model string) bool { + model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) + switch model { + case xai.DefaultTextModel, "grok-4.5-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": + return true + default: + return false + } +} + var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{ "external_web_access": {}, } @@ -675,15 +826,30 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { rawTools := tools.Array() filteredTools := make([]json.RawMessage, 0, len(rawTools)) + toolsChanged := false for _, tool := range rawTools { toolType := strings.TrimSpace(tool.Get("type").String()) if _, ok := grokResponsesSupportedToolTypes[toolType]; ok { - filteredTools = append(filteredTools, json.RawMessage(tool.Raw)) + 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 { + return nil, err + } + payload["parameters"] = map[string]any{"type": "object", "properties": map[string]any{}} + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + raw = encoded + toolsChanged = true + } + filteredTools = append(filteredTools, raw) } } var err error - if len(filteredTools) != len(rawTools) { + if len(filteredTools) != len(rawTools) || toolsChanged { if len(filteredTools) == 0 { body, err = sjson.DeleteBytes(body, "tools") } else { @@ -891,7 +1057,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) // Image-description probes are auxiliary requests, not conversation turns. // Do not bind them to the caller's Grok prompt-cache identity. - upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token, "", s.cfg) + upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, body, token, "", s.cfg, s.settingService) releaseUpstreamCtx() if err != nil { return "", OpenAIUsage{}, fmt.Errorf("build grok composer image bridge request: %w", err) @@ -1061,8 +1227,8 @@ func addOpenAIUsage(dst *OpenAIUsage, usage OpenAIUsage) { dst.ImageOutputTokens += usage.ImageOutputTokens } -func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config) (*http.Request, error) { - targetURL, err := buildGrokResponsesURL(account, cfg) +func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, cacheIdentity string, cfg *config.Config, settings ...*SettingService) (*http.Request, error) { + targetURL, err := buildGrokResponsesURL(account, cfg, settings...) if err != nil { return nil, err } @@ -1090,12 +1256,18 @@ func buildGrokResponsesRequest(ctx context.Context, c *gin.Context, account *Acc // applyGrokCLIHeaders identifies subscription traffic as a supported Grok CLI // version. The CLI gateway rejects otherwise valid OAuth requests without it. +// Identity pins come from package xai so service-layer headers match the final +// transport rewrite on cli-chat-proxy.grok.com. func applyGrokCLIHeaders(headers http.Header) { if headers == nil { return } - headers.Set("User-Agent", grokUpstreamUserAgent) - headers.Set("X-Grok-Client-Version", grokCLIVersion) + version := xai.ResolveCLIVersion() + headers.Set("User-Agent", xai.CLIUserAgent(version)) + headers.Set("X-Grok-Client-Version", version) + headers.Set("x-grok-client-version", version) + headers.Set("x-grok-client-identifier", xai.CLIClientIdentifier) + // Historical mode value expected by some unit tests / older CLI probes. headers.Set("X-Grok-Client-Mode", "interactive") } @@ -1118,6 +1290,15 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco } } + updates := map[string]any{ + grokQuotaSnapshotExtraKey: snapshot, + } + // Also derive the scheduling-threshold extras (grok_sched_*) the evaluator + // reads in grokThresholdCandidates. Without this writer the admin-configured + // Grok auto-pause threshold could never fire (the read side was dead config). + for k, v := range buildGrokSchedulerExtraUpdates(snapshot) { + updates[k] = v + } stateCtx := ctx if hasActiveLimit { var cancel context.CancelFunc @@ -1125,16 +1306,13 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco defer cancel() } if s.accountRepo != nil { - _ = s.accountRepo.UpdateExtra(stateCtx, accountID, map[string]any{ - grokQuotaSnapshotExtraKey: snapshot, - }) + _ = s.accountRepo.UpdateExtra(stateCtx, accountID, updates) } - // Error responses are reconciled by handleGrokAccountUpstreamError, which - // also installs the immediate in-memory scheduling block. Successful - // responses can still consume the last available request/token, so persist - // that exhausted window here as a real rate limit rather than relying only - // on the passive snapshot scheduler check. - if hasActiveLimit { + // Error responses are reconciled by handleGrokAccountUpstreamError. Pool-mode + // 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() { s.rateLimitGrok(stateCtx, account, resetAt) } else if recovery { clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account) @@ -1337,7 +1515,8 @@ func (s *OpenAIGatewayService) rateLimitGrok(ctx context.Context, account *Accou if s == nil || account == nil { return } - resetAt = normalizeGrokRateLimitResetAt(account, resetAt, time.Now()) + now := time.Now() + resetAt = normalizeGrokRateLimitResetAt(account, resetAt, now) runtimeUntil := resetAt if account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(runtimeUntil) { @@ -1345,6 +1524,106 @@ func (s *OpenAIGatewayService) rateLimitGrok(ctx context.Context, account *Accou } s.BlockAccountScheduling(account, runtimeUntil, "429") persistGrokRateLimit(ctx, s.accountRepo, account, resetAt) + + // Propagate a short team+model cool so sibling OAuth accounts on the same + // xAI team skip the hot model without waiting for each to hit 429 alone. + // Model is taken from the latest request context when available; empty is a + // no-op inside markGrokTeamModelRateLimit. + if model, _ := ctx.Value(grokTeamRateLimitModelContextKey{}).(string); model != "" { + markGrokTeamModelRateLimit(account, model, resolveGrokTeamRateLimitUntil(resetAt, now)) + } +} + +// buildGrokSchedulerExtraUpdates derives the grok_sched_* scheduling snapshot +// (utilization percent + reset time) consumed by EvaluateAccountSchedulingThreshold. +// Utilization is the most-constrained of the requests/tokens windows. +func buildGrokSchedulerExtraUpdates(snapshot *xai.QuotaSnapshot) map[string]any { + if snapshot == nil { + return nil + } + util, reset, ok := grokSnapshotUtilization(snapshot) + if !ok { + return nil + } + updates := map[string]any{ + "grok_sched_utilization": util, + "grok_sched_usage_updated_at": time.Now().UTC().Format(time.RFC3339), + } + if reset != nil { + // 防御:调度阈值暂停时长由 grok_sched_reset_at 决定。若上游返回脏的 + // reset 头(例如把相对毫秒 "6000" 误当相对秒解析出 ~33h 的未来时刻), + // 不设上限会把耗尽账号长时间锁死。xAI 配额窗口不会超过一天,因此对 + // 未来时刻做 grokMaxSchedulingResetHorizon 钳制;过去/无效值直接不写。 + now := time.Now() + if reset.After(now) { + capped := *reset + if horizon := now.Add(grokMaxSchedulingResetHorizon); capped.After(horizon) { + capped = horizon + } + updates["grok_sched_reset_at"] = capped.UTC().Format(time.RFC3339) + } + } + return updates +} + +// grokSnapshotUtilization returns the highest window utilization (0-100) across +// the requests/tokens quota windows and the reset time of that window. +func grokSnapshotUtilization(snapshot *xai.QuotaSnapshot) (float64, *time.Time, bool) { + if snapshot == nil { + return 0, nil, false + } + best := -1.0 + var bestReset *time.Time + consider := func(window *xai.QuotaWindow) { + if window == nil || window.Limit == nil || *window.Limit <= 0 || window.Remaining == nil { + return + } + remaining := *window.Remaining + if remaining < 0 { + remaining = 0 + } + util := (1 - float64(remaining)/float64(*window.Limit)) * 100 + if util < 0 { + util = 0 + } + if util > 100 { + util = 100 + } + if util > best { + best = util + if window.ResetUnix != nil { + t := time.Unix(*window.ResetUnix, 0).UTC() + bestReset = &t + } else { + bestReset = nil + } + } + } + consider(snapshot.Requests) + consider(snapshot.Tokens) + if best < 0 { + return 0, nil, false + } + return best, bestReset, true +} + +// grokMaxSchedulingResetHorizon bounds how far into the future a Grok +// scheduling-threshold pause (grok_sched_reset_at) may be set, so a malformed +// upstream reset header can't park an over-threshold account for days. xAI quota +// windows do not exceed ~a day. +const grokMaxSchedulingResetHorizon = 25 * time.Hour + +// grokTeamRateLimitModelContextKey carries the upstream model for team cools. +type grokTeamRateLimitModelContextKey struct{} + +// withGrokTeamRateLimitModel attaches the upstream model name for rate-limit +// side effects (team+model cool). Safe when model is empty. +func withGrokTeamRateLimitModel(ctx context.Context, model string) context.Context { + model = strings.TrimSpace(model) + if model == "" || ctx == nil { + return ctx + } + return context.WithValue(ctx, grokTeamRateLimitModelContextKey{}, model) } func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) { @@ -1356,24 +1635,84 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex } now := time.Now() s.updateGrokUsageSnapshot(ctx, account, parseGrokQuotaSnapshot(headers, statusCode, now)) + + // 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, "") + if decision.ShouldCooldown && decision.Class != GrokFailureNone && decision.Class != GrokFailureRateLimit { + if account.IsPoolMode() { + // Allow configured temp rules (403) below; skip default body cools. + } else { + // A free-tier exhaustion message describes a rolling usage window. Use + // an upstream absolute reset (or Retry-After) when available; otherwise + // apply only a short probe cooldown. Never start a fabricated 24h window + // at the instant this error was received. + if decision.Class == GrokFailureFreeUsage { + if resetAt, limited := grokRateLimitResetAtForAccount(account, parseGrokQuotaSnapshot(headers, statusCode, now), now); limited && resetAt.After(now) { + if decision.Model != "" && isGrokModelSpecificFreeUsage(strings.ToLower(decision.Reason), decision.Model) { + markGrokModelQuotaBlock(account.ID, decision.Model, resetAt) + return + } + s.rateLimitGrok(ctx, account, resetAt) + return + } + } + if s.applyGrokUpstreamFailureDecision(ctx, account, decision) { + return + } + } + } + + if statusCode == http.StatusForbidden && s.applyGrokForbiddenPolicy(ctx, account, responseBody) { + return + } + if account.IsPoolMode() { + slog.Info("grok_pool_mode_error_state_skipped", "account_id", account.ID, "status_code", statusCode) + return + } switch statusCode { case http.StatusUnauthorized: s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized") case http.StatusPaymentRequired: + // 402 without a body-classified billing decision: keep the legacy 30m cool. s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required") case http.StatusForbidden: - if s.applyGrokForbiddenPolicy(ctx, account, responseBody) { + // Spending-limit already handled by body classifier when phrasing matches. + if isGrokSpendingLimitError(responseBody) { + s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now())) return } s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied") case http.StatusTooManyRequests: - // updateGrokUsageSnapshot installs both runtime and durable rate-limit state. + // updateGrokUsageSnapshot installs rate-limit state for non-pool accounts. + // Free-usage 429 was already cooled above via body classification. default: - if statusCode >= 500 && !account.IsPoolMode() { + if statusCode >= 500 { s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error") } } - _ = responseBody +} + +// isGrokSpendingLimitError detects xAI billing exhaustion bodies (often 403, sometimes 402). +func isGrokSpendingLimitError(responseBody []byte) bool { + if len(responseBody) == 0 { + return false + } + code := strings.ToLower(strings.TrimSpace(firstNonEmpty( + gjson.GetBytes(responseBody, "code").String(), + gjson.GetBytes(responseBody, "error.code").String(), + ))) + if code == "personal-team-blocked:spending-limit" { + return true + } + message := strings.ToLower(strings.TrimSpace(firstNonEmpty( + gjson.GetBytes(responseBody, "error").String(), + gjson.GetBytes(responseBody, "error.message").String(), + gjson.GetBytes(responseBody, "message").String(), + ))) + return strings.Contains(message, "spending limit") || + strings.Contains(message, "run out of credits") } func (s *OpenAIGatewayService) tempUnscheduleGrok(ctx context.Context, account *Account, cooldown time.Duration, reason string) { diff --git a/backend/internal/service/openai_gateway_grok_405_test.go b/backend/internal/service/openai_gateway_grok_405_test.go new file mode 100644 index 0000000000..10ed8a599e --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_405_test.go @@ -0,0 +1,29 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldFailoverUpstreamError_405IsFailoverEligible(t *testing.T) { + svc := &OpenAIGatewayService{} + + assert.True(t, svc.shouldFailoverUpstreamError(http.StatusMethodNotAllowed), + "405 should trigger failover so sticky sessions can escape to healthy accounts") +} + +func TestShouldFailoverUpstreamError_ExistingCodesStillWork(t *testing.T) { + svc := &OpenAIGatewayService{} + + failoverCodes := []int{401, 402, 403, 405, 429, 529, 500, 502, 503, 504} + for _, code := range failoverCodes { + assert.True(t, svc.shouldFailoverUpstreamError(code), "status %d should trigger failover", code) + } + + nonFailoverCodes := []int{200, 201, 400, 404, 408, 422} + for _, code := range nonFailoverCodes { + assert.False(t, svc.shouldFailoverUpstreamError(code), "status %d should NOT trigger failover", code) + } +} diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index 170528ce76..82fa39438e 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -104,7 +104,7 @@ func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstream func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string { // Claude Code session is the most stable multi-turn identity for // /v1/messages → Grok bridges. Prefer it over generic session headers so - // prompt cache routing matches CPA behavior. + // prompt cache routing follows the gateway's existing cache affinity rules. seed := extractClaudeCodeSessionID(c, body) if seed == "" { seed = explicitOpenAIHeaderSessionID(c) @@ -118,6 +118,12 @@ func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) stri if seed == "" { seed = strings.TrimSpace(explicitKey) } + // previous_response_id is last-resort: multi-turn Responses without an + // explicit session still share one cache identity (model is already in the + // isolated seed). Message ids are rejected by the seed helper. + if seed == "" && len(body) > 0 { + seed = grokPreviousResponseSessionSeed(body) + } return seed } @@ -298,6 +304,9 @@ func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch) } +// isKnownGrokFreeAccount recognizes free-tier Grok accounts, used for +// Free cache routing / media free_tier blocks (broader than soft-gate). +// Soft-gate uses isExplicitGrokFreeOAuthAccount (exact "free" only). func isKnownGrokFreeAccount(account *Account) bool { if account == nil || !account.IsGrokOAuth() { return false @@ -313,14 +322,12 @@ func isKnownGrokFreeAccount(account *Account) bool { paidSignal = true } } + // Usage % or a monthly dollar cap is evidence of a paid plan. if billing.UsagePercent != nil || billing.UsedPercent != nil || (billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0) { paidSignal = true } - // xAI deliberately reports an empty plan for Free accounts; only paid - // subscriptions receive a SuperGrok plan/monthly limit. A successful - // monthly billing observation with no paid signal is therefore positive - // Free evidence, not an unknown tier. Keep partial probes fail-closed. + // Empty plan + successful monthly observation → inferred free (no paid plan/limit). if strings.TrimSpace(billing.MonthlyUpdatedAt) != "" || (billing.StatusCode >= http.StatusOK && billing.StatusCode < http.StatusMultipleChoices && !billing.Partial && len(billing.FailedWindows) == 0) { @@ -340,6 +347,7 @@ func isKnownGrokFreeAccount(account *Account) bool { inferredFreeSignal = true } } + // Only credentials subscription_tier is authoritative here (not plan_type / extra keys). if tier := strings.TrimSpace(account.GetCredential("subscription_tier")); tier != "" { if isGrokFreeSubscriptionTier(tier) { freeSignal = true @@ -347,9 +355,7 @@ func isKnownGrokFreeAccount(account *Account) bool { paidSignal = true } } - // Explicit paid evidence always wins over an inferred Free signal. This - // protects upgraded/stale accounts whose previous quota snapshot still - // carries the historical 2M Free token limit. + // Explicit paid evidence always wins over an inferred Free signal. return !paidSignal && (freeSignal || inferredFreeSignal) } diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index dc3beaa461..4cc3521c38 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -24,6 +24,36 @@ func newGrokCacheTestContext(apiKeyID int64) *gin.Context { return c } +func TestGrokPreviousResponseSessionSeed(t *testing.T) { + require.Equal(t, "grok-prev-resp:resp_abc123", grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":"resp_abc123"}`))) + require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":"msg_abc123"}`))) + require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{"previous_response_id":""}`))) + require.Empty(t, grokPreviousResponseSessionSeed([]byte(`{}`))) +} + +func TestResolveGrokCacheIdentityUsesPreviousResponseIDWhenNoOtherSeed(t *testing.T) { + gin.SetMode(gin.TestMode) + c := newGrokCacheTestContext(301) + // No prompt_cache_key / headers / reusable prefix — only previous_response_id. + body := []byte(`{"model":"grok","input":[{"role":"user","content":"follow up"}],"previous_response_id":"resp_chain_001"}`) + got := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + require.NotEmpty(t, got) + + // Same previous_response_id → same identity (model already in isolated seed). + again := resolveGrokCacheIdentity(c, body, "", "grok-4.5") + require.Equal(t, got, again) + + // Different model → different identity (model scope). + otherModel := resolveGrokCacheIdentity(c, body, "", "grok-4.3") + require.NotEqual(t, got, otherModel) + + // prompt_cache_key still wins over previous_response_id. + withCache := []byte(`{"model":"grok","prompt_cache_key":"client-session","previous_response_id":"resp_chain_001","input":[{"role":"user","content":"x"}]}`) + cacheID := resolveGrokCacheIdentity(c, withCache, "", "grok-4.5") + require.NotEmpty(t, cacheID) + require.NotEqual(t, got, cacheID) +} + func TestResolveGrokCacheIdentityStableAcrossAppendOnlyTurns(t *testing.T) { gin.SetMode(gin.TestMode) c := newGrokCacheTestContext(101) @@ -858,6 +888,17 @@ func TestGrokFreeMessagesFunctionToolCacheRouteRequiresKnownFreeTier(t *testing. return a }(), }, + { + name: "paid billing overrides stale free credentials", + account: func() *Account { + a := healthyGrokOAuthGatewayTestAccount(9123, "access-token") + a.Credentials["subscription_tier"] = "free" + a.Extra = map[string]any{ + grokBillingExtraKey: map[string]any{"plan": "SuperGrok", "status_code": http.StatusOK}, + } + return a + }(), + }, { name: "partial billing without monthly evidence remains unknown", account: func() *Account { diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 19aa4a8fce..ed910eab43 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -586,7 +586,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return nil, fmt.Errorf("get grok access token: %w", err) } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity, s.cfg) + upstreamReq, err := buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, cacheIdentity, s.cfg, s.settingService) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build grok responses bridge request: %w", err) 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 06f434341a..6c4137d9d8 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge_test.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge_test.go @@ -273,6 +273,47 @@ func TestForwardGrokChatViaResponsesNonStreamingCachesAndReturnsChat(t *testing. require.NotNil(t, repo.updates[account.ID][grokQuotaSnapshotExtraKey]) } +func TestForwardGrokChatViaResponsesNonStreamingRejectsCompletedResponseWithoutUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false,"prompt_cache_key":"stable-session"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, grokChatRawEndpoint, bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7102}) + + account := grokChatBridgeTestAccount(72) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstreamBody := strings.Join([]string{ + `data: {"type":"response.output_text.delta","sequence_number":0,"delta":"ok"}`, + "", + `data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_missing_usage","object":"response","model":"grok-4.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`, + "", + }, "\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, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, grokMissingUsageErrorCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") + require.Empty(t, recorder.Body.String()) +} + func TestForwardGrokChatViaResponsesCodeBuddyUsesStableConversationHeader(t *testing.T) { gin.SetMode(gin.TestMode) const conversationID = "codebuddy-session-42" diff --git a/backend/internal/service/openai_gateway_grok_search_billing_test.go b/backend/internal/service/openai_gateway_grok_search_billing_test.go new file mode 100644 index 0000000000..dc998ec5dd --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_search_billing_test.go @@ -0,0 +1,187 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestForwardGrokResponses_PropagatesSearchCountFromJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"grok","input":"search something","tools":[{"type":"web_search"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + account := healthyGrokOAuthGatewayTestAccount(9901, "access-token") + repo := &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}} + upstreamBody := `{ + "id":"resp_search_bill", + "object":"response", + "model":"grok-4.5", + "status":"completed", + "output":[ + {"type":"web_search_call","id":"ws1","call_id":"c1","status":"completed"}, + {"type":"x_search_call","id":"xs1","call_id":"c2"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]} + ], + "usage":{"input_tokens":10,"output_tokens":5} + }` + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(upstreamBody))), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now()) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 2, result.SearchCount, "Grok Responses must surface search tool calls for surcharge billing") + require.Equal(t, 10, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) +} + +func TestForwardGrokResponses_PropagatesSearchCountFromSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"grok","input":"search","tools":[{"type":"web_search"}],"stream":true}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + account := healthyGrokOAuthGatewayTestAccount(9902, "access-token") + repo := &mockAccountRepoForPlatform{accountsByID: map[int64]*Account{account.ID: account}} + // item.done + response.completed for same call_id must count once after wire-up. + sse := "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"web_search_call\",\"id\":\"ws1\",\"call_id\":\"c1\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s\",\"status\":\"completed\",\"output\":[{\"type\":\"web_search_call\",\"id\":\"ws1\",\"call_id\":\"c1\"}],\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(bytes.NewReader([]byte(sse))), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", true, time.Now()) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.SearchCount, "stream SearchCount must be wired and deduped") +} + +func TestGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { + // Sticky/non-list path must not return over-gate free OAuth accounts once cache is warm. + // First sticky hit fail-opens and schedules async refresh; subsequent hits use the cache. + cfg := &config.Config{} + cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 + cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 + cfg.Gateway.Grok.FreeQuotaWindowHours = 24 + cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60 + + account := healthyGrokOAuthGatewayTestAccount(8801, "tok") + account.Credentials["subscription_tier"] = "free" + account.Status = StatusActive + account.Schedulable = true + + repo := &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + } + usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + account.ID: {Tokens: 480_000}, // above 95% of 500k + }} + // Clear shared gateway free-gate cache so this test is deterministic. + gatewayGrokFreeQuotaGateCache.Range(func(key, _ any) bool { + gatewayGrokFreeQuotaGateCache.Delete(key) + return true + }) + if root, ok := freeQuotaRefreshInFlight.Load(&gatewayGrokFreeQuotaGateCache); ok { + if m, ok := root.(*sync.Map); ok { + m.Delete(account.ID) + } + } + svc := &GatewayService{ + cfg: cfg, + accountRepo: repo, + usageLogRepo: usageRepo, + } + + // Miss: fail open + schedule refresh. + got, err := svc.getSchedulableAccount(context.Background(), account.ID) + require.NoError(t, err) + require.NotNil(t, got, "first sticky hit fail-opens while free-gate stats refresh") + + require.Eventually(t, func() bool { + got, err := svc.getSchedulableAccount(context.Background(), account.ID) + return err == nil && got == nil + }, 2*time.Second, 10*time.Millisecond, "over free soft-gate sticky hit must miss after cache warm") +} + +func TestOpenAIGetSchedulableAccount_AppliesGrokFreeSoftGate(t *testing.T) { + // Legacy OpenAI-compatible sticky (advanced scheduler off) must free-gate Grok. + cfg := &config.Config{} + cfg.Gateway.Grok.FreeQuotaSoftGateEnabled = true + cfg.Gateway.Grok.FreeQuotaTokenLimit = 500_000 + cfg.Gateway.Grok.FreeQuotaSoftGatePercent = 95 + cfg.Gateway.Grok.FreeQuotaWindowHours = 24 + cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds = 60 + + account := healthyGrokOAuthGatewayTestAccount(8802, "tok") + account.Credentials["subscription_tier"] = "free" + account.Status = StatusActive + account.Schedulable = true + + repo := &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + } + usageRepo := &grokFreeQuotaUsageRepoStub{stats: map[int64]*usagestats.AccountStats{ + account.ID: {Tokens: 480_000}, + }} + openaiGrokFreeQuotaGateCache.Range(func(key, _ any) bool { + openaiGrokFreeQuotaGateCache.Delete(key) + return true + }) + if root, ok := freeQuotaRefreshInFlight.Load(&openaiGrokFreeQuotaGateCache); ok { + if m, ok := root.(*sync.Map); ok { + m.Delete(account.ID) + } + } + svc := &OpenAIGatewayService{ + cfg: cfg, + accountRepo: repo, + usageLogRepo: usageRepo, + } + + got, err := svc.getSchedulableAccount(context.Background(), account.ID) + require.NoError(t, err) + require.NotNil(t, got, "first sticky hit fail-opens while free-gate stats refresh") + + require.Eventually(t, func() bool { + got, err := svc.getSchedulableAccount(context.Background(), account.ID) + return err == nil && got == nil + }, 2*time.Second, 10*time.Millisecond, "OpenAI legacy sticky must apply free soft-gate after cache warm") +} + +func TestCountGrokNativeSearchCallsFromJSON_MessagesStyleBody(t *testing.T) { + // Proves the same counter used by Anthropic-buffered Grok /v1/messages path. + body := []byte(`{"id":"r1","output":[{"type":"web_search_call","id":"ws1"},{"type":"message","role":"assistant"}]}`) + require.Equal(t, 1, countGrokNativeSearchCallsFromJSONBytes(body)) +} diff --git a/backend/internal/service/openai_gateway_grok_sse_filter.go b/backend/internal/service/openai_gateway_grok_sse_filter.go new file mode 100644 index 0000000000..78159144d5 --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_sse_filter.go @@ -0,0 +1,248 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "sync" +) + +// OpenAI Responses SSE event types are a closed enum for strict clients +// (grok CLI, Codex CLI): an unknown `event: ping` frame aborts the whole +// turn. Vendor gateways behind Grok subscriptions inject such frames for +// billing/keepalive, so ping frames are rewritten into an SSE comment that +// every parser ignores while the connection still looks alive downstream. +var grokResponsesPingComment = []byte(": ping\n\n") + +// A vendor ping frame is one event line plus one small data line. Cap what is +// buffered while deciding, so an upstream streaming a frame that never ends +// cannot grow gateway memory; frames over the cap are passed through as-is. +const ( + grokResponsesPingFrameMaxLines = 16 + grokResponsesPingFrameMaxBytes = 16 * 1024 +) + +type grokResponsesBillingPingFilterBody struct { + *io.PipeReader + source io.Closer + closeOnce sync.Once + closeErr error +} + +func (b *grokResponsesBillingPingFilterBody) Close() error { + readerErr := b.PipeReader.Close() + sourceErr := b.closeSource() + if readerErr != nil { + return readerErr + } + return sourceErr +} + +func (b *grokResponsesBillingPingFilterBody) closeSource() error { + b.closeOnce.Do(func() { b.closeErr = b.source.Close() }) + return b.closeErr +} + +func newGrokResponsesBillingPingFilterBody(source io.ReadCloser, account *Account, maxLineSize int) io.ReadCloser { + if account == nil || account.Platform != PlatformGrok { + return source + } + reader, writer := io.Pipe() + body := &grokResponsesBillingPingFilterBody{PipeReader: reader, source: source} + go filterGrokResponsesBillingPings(source, writer, body.closeSource, maxLineSize) + return body +} + +func filterGrokResponsesBillingPings( + source io.Reader, + destination *io.PipeWriter, + closeSource func() error, + maxLineSize int, +) { + defer func() { _ = closeSource() }() + if maxLineSize <= 0 { + maxLineSize = defaultMaxLineSize + } + + scanner := bufio.NewScanner(source) + scanBuf := getSSEScannerBuf64K() + defer putSSEScannerBuf64K(scanBuf) + initialBufferSize := len(scanBuf) + if maxLineSize < initialBufferSize { + initialBufferSize = maxLineSize + } + scanner.Buffer(scanBuf[:0:initialBufferSize], maxLineSize) + scanner.Split(scanSSELinesPreservingEndings) + + // Only frames opened by an `event: ping` line are buffered (pingFrame); + // every other frame streams through line by line without copying. + pingFrame := make([][]byte, 0, 3) + pingFrameBytes := 0 + inPassthroughFrame := false + + replayPingFrame := func() error { + for _, line := range pingFrame { + if _, err := destination.Write(line); err != nil { + return err + } + } + pingFrame = pingFrame[:0] + pingFrameBytes = 0 + return nil + } + // endPingFrame decides a complete buffered candidate: vendor ping frames + // become an SSE comment, everything else is replayed verbatim. blankLine + // is nil when the stream ends inside the frame. + endPingFrame := func(blankLine []byte) error { + if isGrokResponsesPingEventFrame(pingFrame) { + pingFrame = pingFrame[:0] + pingFrameBytes = 0 + _, err := destination.Write(grokResponsesPingComment) + return err + } + if err := replayPingFrame(); err != nil { + return err + } + if blankLine == nil { + return nil + } + _, err := destination.Write(blankLine) + return err + } + abort := func(err error) { _ = destination.CloseWithError(err) } + + for scanner.Scan() { + line := scanner.Bytes() + isBlank := len(trimSSELineEnding(line)) == 0 + + if inPassthroughFrame { + if _, err := destination.Write(line); err != nil { + abort(err) + return + } + if isBlank { + inPassthroughFrame = false + } + continue + } + + if len(pingFrame) > 0 { + if isBlank { + if err := endPingFrame(line); err != nil { + abort(err) + return + } + continue + } + if canExtendGrokResponsesPingFrame(line) && + len(pingFrame) < grokResponsesPingFrameMaxLines && + pingFrameBytes+len(line) <= grokResponsesPingFrameMaxBytes { + pingFrame = append(pingFrame, append([]byte(nil), line...)) + pingFrameBytes += len(line) + continue + } + // Not a filterable ping frame after all (unexpected field line, + // or past the buffering caps): replay it and stream the rest of + // the frame through unchanged. + if err := replayPingFrame(); err != nil { + abort(err) + return + } + if _, err := destination.Write(line); err != nil { + abort(err) + return + } + inPassthroughFrame = true + continue + } + + // Frame start: only `event: ping` opens a buffered candidate. + if !isBlank { + if value, ok := extractOpenAISSEEventLine(string(trimSSELineEnding(line))); ok && value == "ping" { + pingFrame = append(pingFrame, append([]byte(nil), line...)) + pingFrameBytes = len(line) + continue + } + } + if _, err := destination.Write(line); err != nil { + abort(err) + return + } + inPassthroughFrame = !isBlank + } + if len(pingFrame) > 0 { + if err := endPingFrame(nil); err != nil { + abort(err) + return + } + } + if err := scanner.Err(); err != nil { + abort(fmt.Errorf("filter Grok Responses billing ping: %w", err)) + return + } + _ = destination.Close() +} + +func scanSSELinesPreservingEndings(data []byte, atEOF bool) (advance int, token []byte, err error) { + for index, value := range data { + switch value { + case '\n': + return index + 1, data[:index+1], nil + case '\r': + if index+1 == len(data) && !atEOF { + return 0, nil, nil + } + if index+1 < len(data) && data[index+1] == '\n' { + return index + 2, data[:index+2], nil + } + return index + 1, data[:index+1], nil + } + } + if atEOF && len(data) > 0 { + return len(data), data, nil + } + return 0, nil, nil +} + +func trimSSELineEnding(line []byte) []byte { + return bytes.TrimSuffix(bytes.TrimSuffix(line, []byte("\n")), []byte("\r")) +} + +// canExtendGrokResponsesPingFrame reports whether a line may still belong to a +// vendor ping frame: only data lines and SSE comments. Any other field (a +// second event line, id, retry, ...) means the frame is not a plain ping. +func canExtendGrokResponsesPingFrame(rawLine []byte) bool { + line := trimSSELineEnding(rawLine) + if len(line) > 0 && line[0] == ':' { + return true + } + _, ok := extractOpenAISSEDataLine(string(line)) + return ok +} + +// isGrokResponsesPingEventFrame decides a buffered candidate whose first line +// is already `event: ping`. The only candidates replayed verbatim are frames +// whose data payload declares a different event type than the SSE event line; +// every other shape (billing cost, keepalive, no data, malformed JSON) would +// break strict Responses clients and is rewritten into a comment. +func isGrokResponsesPingEventFrame(rawLines [][]byte) bool { + dataParts := make([]string, 0, 1) + for _, rawLine := range rawLines[1:] { + if value, ok := extractOpenAISSEDataLine(string(trimSSELineEnding(rawLine))); ok { + dataParts = append(dataParts, value) + } + } + if len(dataParts) == 0 { + return true + } + var payload struct { + Type *string `json:"type"` + } + if err := json.Unmarshal([]byte(strings.Join(dataParts, "\n")), &payload); err != nil || payload.Type == nil { + return true + } + return *payload.Type == "ping" +} diff --git a/backend/internal/service/openai_gateway_grok_sse_filter_test.go b/backend/internal/service/openai_gateway_grok_sse_filter_test.go new file mode 100644 index 0000000000..1dce65e95a --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_sse_filter_test.go @@ -0,0 +1,262 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func filterGrokPingTestInput(t *testing.T, input string) string { + t.Helper() + body := newGrokResponsesBillingPingFilterBody( + io.NopCloser(strings.NewReader(input)), + &Account{Platform: PlatformGrok}, + defaultMaxLineSize, + ) + output, err := io.ReadAll(body) + require.NoError(t, err) + require.NoError(t, body.Close()) + return string(output) +} + +func TestGrokResponsesBillingPingFilter(t *testing.T) { + input := strings.Join([]string{ + ": upstream keepalive", + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"hello"}`, + "", + "event: future.vendor_event", + `data: {"type":"future.vendor_event","value":1}`, + "", + "event: ping", + `data: {"type":"ping","x-opencode-type":"inference-cost","cost":2.75,"input-tokens":42}`, + "", + "event: ping", + `data: {"type":"ping","cost":"0"}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`, + "", + }, "\n") + + result := filterGrokPingTestInput(t, input) + require.NotContains(t, result, "event: ping") + require.NotContains(t, result, `"x-opencode-type":"inference-cost"`) + require.NotContains(t, result, `{"type":"ping","cost":"0"}`) + require.Equal(t, 2, strings.Count(result, ": ping\n\n")) + require.Contains(t, result, ": upstream keepalive\n\n") + require.Contains(t, result, "event: response.output_text.delta") + require.Contains(t, result, `{"type":"response.output_text.delta","delta":"hello"}`) + require.Contains(t, result, "event: future.vendor_event") + require.Contains(t, result, `{"type":"future.vendor_event","value":1}`) + require.Contains(t, result, "event: response.completed") + require.Contains(t, result, `"usage":{"input_tokens":3,"output_tokens":5}`) +} + +// Every `event: ping` frame is outside the Responses closed event enum and +// breaks strict clients regardless of its payload shape, so all variants are +// rewritten into an SSE comment (issue #5105). +func TestGrokResponsesBillingPingFilterConvertsPingVariants(t *testing.T) { + frames := []string{ + "event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"inference-cost\",\"cost\":\"0.06029240\"}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0.06029240\"}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"kind\":\"keepalive\"}\n\n", + "event: ping\ndata: {\"type\":\"ping\"}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"cost\":2}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"cost\":0.0001}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"cost\":\" 0 \"}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"keepalive\",\"cost\":0}\n\n", + "event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":null,\"cost\":0}\n\n", + "event: ping\ndata: {\"cost\":\"0\"}\n\n", + "event: ping\ndata: {not-json}\n\n", + "event: ping\n\n", + "event: ping\n: vendor note\ndata: {\"type\":\"ping\"}\n\n", + } + result := filterGrokPingTestInput(t, strings.Join(frames, "")) + require.Equal(t, strings.Repeat(": ping\n\n", len(frames)), result) +} + +func TestGrokResponsesBillingPingFilterPreservesNonPingFrames(t *testing.T) { + input := strings.Join([]string{ + "event: ping", + `data: {"type":" ping ","cost":0}`, + "", + "event: ping", + `data: {"type":"response.completed"}`, + "", + "event: custom", + `data: {"type":"ping","x-opencode-type":"inference-cost"}`, + "", + `data: {"type":"ping","cost":"0"}`, + "", + ": keepalive comment", + "", + "retry: 1000", + "", + }, "\n") + + require.Equal(t, input, filterGrokPingTestInput(t, input)) +} + +// A ping candidate that turns out to carry an unexpected SSE field is not a +// vendor billing/keepalive frame; it must be replayed byte for byte. +func TestGrokResponsesBillingPingFilterPassesThroughPingFrameWithUnknownField(t *testing.T) { + input := "event: ping\nid: 7\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n" + require.Equal(t, input, filterGrokPingTestInput(t, input)) +} + +// Buffering caps: a ping candidate that grows past the line or byte limit is +// streamed through unchanged instead of accumulating unbounded memory. +func TestGrokResponsesBillingPingFilterPassesThroughOversizedPingFrame(t *testing.T) { + lines := []string{"event: ping"} + for i := 0; i < grokResponsesPingFrameMaxLines; i++ { + lines = append(lines, ": filler comment") + } + lines = append(lines, `data: {"type":"ping","cost":"0"}`, "") + byLines := strings.Join(lines, "\n") + require.Equal(t, byLines, filterGrokPingTestInput(t, byLines)) + + byBytes := "event: ping\ndata: {\"type\":\"ping\",\"pad\":\"" + + strings.Repeat("x", grokResponsesPingFrameMaxBytes) + "\"}\n\n" + require.Equal(t, byBytes, filterGrokPingTestInput(t, byBytes)) +} + +func TestGrokResponsesBillingPingFilterConvertsMalformedPingFrames(t *testing.T) { + input := "event: ping\r\ndata: {not-json}\r\n\r\n" + + "event: ping\r\ndata: {\"type\":\"ping\",\"cost\":\"0\"} trailing\r\n\r\n" + + "event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}" + want := ": ping\n\n" + ": ping\n\n" + + "event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}" + require.Equal(t, want, filterGrokPingTestInput(t, input)) +} + +func TestGrokResponsesBillingPingFilterHandlesBareCRFrames(t *testing.T) { + input := "event: ping\rdata: {\"type\":\"ping\",\"cost\":\"0\"}\r\r" + + "event: future.event\rdata: {\"type\":\"future.event\"}\r\r" + want := ": ping\n\n" + "event: future.event\rdata: {\"type\":\"future.event\"}\r\r" + require.Equal(t, want, filterGrokPingTestInput(t, input)) +} + +func TestGrokResponsesBillingPingFilterConvertsPartialPingFrameAtEOF(t *testing.T) { + input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}" + require.Equal(t, ": ping\n\n", filterGrokPingTestInput(t, input)) +} + +func TestGrokResponsesBillingPingFilterDoesNotFilterNonGrokAccounts(t *testing.T) { + input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n" + source := io.NopCloser(strings.NewReader(input)) + body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformOpenAI}, defaultMaxLineSize) + + output, err := io.ReadAll(body) + require.NoError(t, err) + require.NoError(t, body.Close()) + require.Equal(t, input, string(output)) +} + +func TestGrokResponsesBillingPingFilterPreservesUsageAndTerminalEvent(t *testing.T) { + gin.SetMode(gin.TestMode) + input := strings.Join([]string{ + "event: ping", + `data: {"type":"ping","x-opencode-type":"inference-cost","cost":"0"}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`, + "", + }, "\n") + account := &Account{ID: 1, Platform: PlatformGrok} + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: newGrokResponsesBillingPingFilterBody( + io.NopCloser(strings.NewReader(input)), account, defaultMaxLineSize, + ), + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + toolCorrector: NewCodexToolCorrector(), + } + + result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "grok-4.5", "grok-4.5") + require.NoError(t, err) + require.Equal(t, 3, result.usage.InputTokens) + require.Equal(t, 5, result.usage.OutputTokens) + require.Equal(t, "resp_1", result.responseID) + require.Contains(t, recorder.Body.String(), "response.completed") + require.NotContains(t, recorder.Body.String(), "inference-cost") + require.NotContains(t, recorder.Body.String(), "event: ping") +} + +type grokPingFilterTestReadCloser struct { + reader io.ReadCloser + closeCount atomic.Int32 +} + +func (r *grokPingFilterTestReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) } +func (r *grokPingFilterTestReadCloser) Close() error { + r.closeCount.Add(1) + return r.reader.Close() +} + +func TestGrokResponsesBillingPingFilterCloseCancelsSourceOnce(t *testing.T) { + upstreamReader, upstreamWriter := io.Pipe() + source := &grokPingFilterTestReadCloser{reader: upstreamReader} + body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformGrok}, defaultMaxLineSize) + + require.NoError(t, body.Close()) + require.Eventually(t, func() bool { return source.closeCount.Load() == 1 }, time.Second, time.Millisecond) + _, err := upstreamWriter.Write([]byte("blocked")) + require.Error(t, err) + require.NoError(t, upstreamWriter.Close()) +} + +func TestGrokResponsesBillingPingFilterFlushesCompletedFrames(t *testing.T) { + upstreamReader, upstreamWriter := io.Pipe() + body := newGrokResponsesBillingPingFilterBody(upstreamReader, &Account{Platform: PlatformGrok}, defaultMaxLineSize) + t.Cleanup(func() { require.NoError(t, body.Close()) }) + + go func() { + _, _ = io.WriteString(upstreamWriter, "event: future.event\ndata: {\"type\":\"future.event\"}\n\n") + }() + + result := make(chan error, 1) + go func() { + buffer := make([]byte, 64) + n, err := body.Read(buffer) + if err == nil && !strings.Contains(string(buffer[:n]), "future.event") { + err = errors.New("completed frame was not forwarded") + } + result <- err + }() + select { + case err := <-result: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("completed frame was buffered until upstream EOF") + } +} + +func TestGrokResponsesBillingPingFilterReportsOversizedLine(t *testing.T) { + body := newGrokResponsesBillingPingFilterBody( + io.NopCloser(strings.NewReader("data: 123456789\n\n")), + &Account{Platform: PlatformGrok}, + 8, + ) + _, err := io.ReadAll(body) + require.ErrorContains(t, err, "filter Grok Responses billing ping") + require.NoError(t, body.Close()) +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 09a0df4019..04edd0a4a6 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -58,7 +58,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T {name: "grok 4.5", upstreamModel: "grok-4.5", wantReasoning: true}, } - body := []byte(`{ + bodyTemplate := []byte(`{ "model": "grok", "input": "hello", "reasoning": {"effort": "medium", "summary": "auto"}, @@ -68,7 +68,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - patched, err := patchGrokResponsesBody(body, tt.upstreamModel) + patched, err := patchGrokResponsesBody(append([]byte(nil), bodyTemplate...), tt.upstreamModel) require.NoError(t, err) require.True(t, json.Valid(patched)) require.Equal(t, tt.upstreamModel, gjson.GetBytes(patched, "model").String()) @@ -76,7 +76,7 @@ func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T if tt.wantReasoning { require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning.effort").String()) require.Equal(t, "medium", gjson.GetBytes(patched, "reasoning_effort").String()) - require.Equal(t, "medium", gjson.GetBytes(patched, "reasoningEffort").String()) + require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) return } @@ -142,6 +142,61 @@ func TestPatchGrokResponsesBodyKeepsPenaltyAndStopFieldsForNon45Models(t *testin require.Len(t, gjson.GetBytes(patched, "stop").Array(), 1) } +func TestPatchGrokResponsesBodyDropsLogprobsForGrok420Family(t *testing.T) { + t.Parallel() + body := []byte(`{"model":"grok-4.20-0309-reasoning","input":"hello","logprobs":true,"top_logprobs":5}`) + patched, err := patchGrokResponsesBody(body, "grok-4.20-0309-reasoning") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "logprobs").Exists()) + require.False(t, gjson.GetBytes(patched, "top_logprobs").Exists()) +} + +func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body 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"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.5") + 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()) + }) + } +} + +func TestPatchGrokResponsesBodyAddsDefaultFunctionParameters(t *testing.T) { + patched, err := patchGrokResponsesBody( + []byte(`{"input":"hi","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"wait","parameters":null}]}`), + "grok-4.5", + ) + require.NoError(t, err) + for _, tool := range gjson.GetBytes(patched, "tools").Array() { + require.Equal(t, "object", tool.Get("parameters.type").String(), string(patched)) + require.True(t, tool.Get("parameters.properties").IsObject(), string(patched)) + } +} + +func TestNormalizeGrokChatReasoningEffort(t *testing.T) { + patched, err := normalizeGrokChatReasoningEffort([]byte(`{"reasoningEffort":"ultra"}`), "grok-4.3") + require.NoError(t, err) + require.Equal(t, "high", gjson.GetBytes(patched, "reasoning_effort").String()) + require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) + + 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()) +} + func TestPatchGrokResponsesBodyDropsNestedUnsupportedFields(t *testing.T) { t.Parallel() @@ -857,6 +912,33 @@ func TestCanonicalizeGrokMediaImageURLFieldsReplacesEmptyOfficialURL(t *testing. require.False(t, gjson.GetBytes(out, "image.image_url").Exists()) } +func TestPrepareGrokImageEditNormalizesOfficialImageObjects(t *testing.T) { + body := []byte(`{ + "model":"grok-imagine-image-quality", + "image":{"image_url":{"url":"https://example.com/first.png"}}, + "images":["https://example.com/second.png"], + "mask":{"image_url":"https://example.com/mask.png"} + }`) + + out, contentType, err := prepareGrokMediaForwardBody(GrokMediaEndpointImagesEdits, body, "application/json") + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + for _, path := range []string{"image", "images.0", "mask"} { + require.Equal(t, "image_url", gjson.GetBytes(out, path+".type").String()) + require.NotEmpty(t, gjson.GetBytes(out, path+".url").String()) + require.False(t, gjson.GetBytes(out, path+".image_url").Exists()) + } +} + +func TestPrepareGrokImageEditRejectsMoreThanThreeSources(t *testing.T) { + body := []byte(`{"images":["https://example.com/1.png","https://example.com/2.png","https://example.com/3.png","https://example.com/4.png"]}`) + + out, _, err := prepareGrokMediaForwardBody(GrokMediaEndpointImagesEdits, body, "application/json") + require.Error(t, err) + require.Nil(t, out) + require.Contains(t, err.Error(), "maximum of 3 source images") +} + func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { tests := []struct { name string @@ -870,7 +952,7 @@ func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { {name: "image quality passthrough", endpoint: GrokMediaEndpointImagesGenerations, model: "grok-imagine-image-quality", want: "grok-imagine-image-quality"}, {name: "image fast passthrough", endpoint: GrokMediaEndpointImagesGenerations, model: "grok-imagine-image", want: "grok-imagine-image"}, {name: "video passthrough", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video", want: "grok-imagine-video"}, - {name: "video 1.5 text-only fallback", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", want: "grok-imagine-video"}, + {name: "video 1.5 text-only remains explicit", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", want: "grok-imagine-video-1.5"}, {name: "video 1.5 image-to-video passthrough", endpoint: GrokMediaEndpointVideosGenerations, model: "grok-imagine-video-1.5", hasInputImage: true, want: "grok-imagine-video-1.5"}, } @@ -962,9 +1044,9 @@ func TestForwardGrokMediaAppliesAccountModelMappingAfterEndpointNormalization(t path: "/v1/videos/generations", body: `{"model":"grok-imagine-video-1.5","prompt":"waves"}`, modelMapping: map[string]any{"grok-imagine-video": "grok-image-video"}, - wantRequestModel: "grok-imagine-video", - wantUpstream: "grok-image-video", - wantBody: `{"model":"grok-image-video","prompt":"waves"}`, + wantRequestModel: "grok-imagine-video-1.5", + wantUpstream: "grok-imagine-video-1.5", + wantBody: `{"model":"grok-imagine-video-1.5","prompt":"waves"}`, responseBody: `{"request_id":"video-request-mapped"}`, }, { @@ -1204,18 +1286,64 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) - require.JSONEq(t, `{"model":"grok-imagine-video","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"waves","resolution":"720p","duration":10}`, string(upstream.lastBody)) require.Equal(t, "video-request-123", result.ResponseID) - require.Equal(t, "grok-imagine-video", result.BillingModel) + require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) require.Equal(t, 3, result.Usage.InputTokens) require.Equal(t, 4, result.Usage.OutputTokens) - require.Equal(t, 1, result.ImageCount) + // Create accepts the job only — VideoCount stays 0 until status returns video.url. + require.Equal(t, 0, result.ImageCount) require.Empty(t, result.ImageSize) - require.Equal(t, 1, result.VideoCount) + require.Equal(t, 0, result.VideoCount) require.Equal(t, VideoBillingResolution720P, result.VideoResolution) require.Equal(t, 10, result.VideoDurationSeconds) } +func TestForwardGrokMediaVideoGenerationReturnsTaskIDAsResponseID(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine-video","prompt":"waves"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 63, + 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(`{"task_id":"video-task-123"}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") + require.NoError(t, err) + require.Equal(t, "video-task-123", result.ResponseID) +} + +func TestExtractGrokMediaVideoRequestIDPreservesExistingPrecedence(t *testing.T) { + body := []byte(`{ + "request_id":"request-id", + "id":"id", + "task_id":"task-id", + "data":{"request_id":"data-request-id","id":"data-id","task_id":"data-task-id"}, + "video":{"request_id":"video-request-id","id":"video-id","task_id":"video-task-id"} + }`) + + require.Equal(t, "request-id", extractGrokMediaVideoRequestID(body)) +} + func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) @@ -1383,7 +1511,7 @@ func TestForwardGrokMediaVideoMutationEndpoints(t *testing.T) { require.Equal(t, http.MethodPost, upstream.lastReq.Method) require.JSONEq(t, `{"model":"vendor-video-mutation","prompt":"continue","video":{"url":"https://example.com/in.mp4"},"duration":6}`, string(upstream.lastBody)) require.Equal(t, "video-mutation-123", result.ResponseID) - require.Equal(t, 1, result.VideoCount) + require.Equal(t, 0, result.VideoCount) require.Equal(t, 6, result.VideoDurationSeconds) require.Equal(t, "grok-imagine-video", result.BillingModel) require.Equal(t, "vendor-video-mutation", result.UpstreamModel) @@ -1937,6 +2065,43 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) } +func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok","messages":[{"role":"user","content":"hi"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + account := &Account{ + ID: 707, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "third-party-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":"resp_missing_usage","object":"chat.completion","model":"grok-4.5","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`, + )), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, grokMissingUsageErrorCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.False(t, c.Writer.Written(), "an unbillable response must not be committed to the client") + require.Empty(t, recorder.Body.String()) +} + func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1964,7 +2129,7 @@ func TestAccountTestServiceGrokAPIKeyUsesXAIResponses(t *testing.T) { c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/54/test", nil) - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.NoError(t, err) 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")) @@ -1998,7 +2163,7 @@ func TestAccountTestServiceGrokAPIKeyAllowsConfiguredHTTPWhenGlobalPolicyDoes(t c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/55/test", nil) - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.NoError(t, err) require.Equal(t, "http://grok.example.test/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization")) @@ -2026,13 +2191,13 @@ func TestAccountTestServiceGrokOAuthPaymentRequiredTemporarilyUnschedulesAccount c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/56/test", nil) before := time.Now() - err := svc.testGrokAccountConnection(c, account, "grok") + err := svc.testGrokAccountConnection(c, account, "grok", "", AccountTestModeDefault, AccountTestOptions{}) require.Error(t, err) - require.Equal(t, 1, repo.tempUnschedCalls) - require.Equal(t, account.ID, repo.lastTempUnschedID) - require.Equal(t, "grok payment required", repo.lastTempUnschedReason) - require.WithinDuration(t, before.Add(30*time.Minute), repo.lastTempUnschedUntil, time.Second) + require.Zero(t, repo.tempUnschedCalls) + require.Equal(t, 1, repo.rateLimitedCalls) + require.Equal(t, account.ID, repo.lastRateLimitedID) + require.WithinDuration(t, before.Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, time.Second) require.Contains(t, recorder.Body.String(), `"type":"error"`) require.Contains(t, recorder.Body.String(), "Grok Responses API returned 402") } @@ -2082,7 +2247,7 @@ func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *te require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String()) 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, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) + 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.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool()) require.True(t, result.Stream) @@ -2255,7 +2420,7 @@ func TestForwardAsChatCompletionsForGrokStreamingStopFallsBackToRawXAIChatComple require.Equal(t, xai.DefaultCLIBaseURL+"/chat/completions", upstream.lastReq.URL.String()) 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, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) + 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.NotEmpty(t, upstream.lastReq.Header.Get(grokConversationIDHeader)) require.NotEqual(t, "native-client-conversation", upstream.lastReq.Header.Get(grokConversationIDHeader)) @@ -2359,7 +2524,7 @@ func TestForwardAsAnthropicForGrokUsesXAIResponses(t *testing.T) { require.NoError(t, err) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) + 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-experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Empty(t, upstream.lastReq.Header.Get("originator")) @@ -2501,6 +2666,20 @@ func grokMessagesSSECompletedResponse(responseID string, cachedTokens int) *http } } +func TestHandleGrokAccountUpstreamErrorSpendingLimitUsesRecoverableProbeCool(t *testing.T) { + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := &Account{ID: 2570, Platform: PlatformGrok, Type: AccountTypeOAuth} + before := time.Now() + body := []byte(`{"code":"personal-team-blocked:spending-limit","error":"You have run out of credits"}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) + + require.Equal(t, 1, repo.rateLimitedCalls) + require.WithinDuration(t, before.Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) + require.Zero(t, repo.tempUnschedCalls) +} + func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *testing.T) { tests := []struct { name string @@ -2560,6 +2739,23 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test } } +func TestHandleGrokAccountUpstreamErrorSpendingLimit403RateLimits(t *testing.T) { + account := &Account{ID: 614, Platform: PlatformGrok, Type: AccountTypeOAuth} + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + before := time.Now() + body := []byte(`{"code":"personal-team-blocked:spending-limit","error":"You have run out of credits"}`) + + svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body) + + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Equal(t, 1, repo.rateLimitedCalls) + require.Equal(t, account.ID, repo.lastRateLimitedID) + require.WithinDuration(t, before.Add(grokSpendingLimitProbeCooldown), repo.lastRateLimitResetAt, 2*time.Second) + require.Zero(t, repo.tempUnschedCalls) + require.True(t, isGrokSpendingLimitError(body)) +} + func TestHandleGrokAccountUpstreamError5xxRespectsPoolMode(t *testing.T) { t.Run("pool mode keeps scheduling state", func(t *testing.T) { account := &Account{ @@ -2612,6 +2808,30 @@ func TestHandleGrokAccountUpstreamError429SetsRateLimitedFromRetryAfter(t *testi require.Zero(t, repo.tempUnschedCalls) } +func TestHandleGrokAccountUpstreamError429PoolModeKeepsSchedulingState(t *testing.T) { + account := &Account{ + ID: 613, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "pool_mode": true, + }, + } + repo := &grokQuotaAccountRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + + svc.handleGrokAccountUpstreamError( + context.Background(), account, http.StatusTooManyRequests, + http.Header{"Retry-After": []string{"45"}}, nil, + ) + + require.Equal(t, 1, repo.updateCalls, "pool mode should retain the quota snapshot for observability") + require.Zero(t, repo.rateLimitedCalls) + require.Zero(t, repo.tempUnschedCalls) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.Nil(t, account.RateLimitResetAt) +} + func TestHandleGrokAccountUpstreamError402RecoversAfterCooldownExpiry(t *testing.T) { account := &Account{ ID: 610, Platform: PlatformGrok, Type: AccountTypeOAuth, @@ -3166,3 +3386,30 @@ func TestIsGrokImageGenerationModel(t *testing.T) { }) } } + +func TestBuildGrokSchedulerExtraUpdates_FeedsThresholdEvaluator(t *testing.T) { + int64p := func(v int64) *int64 { return &v } + resetUnix := time.Now().Add(90 * time.Minute).Unix() + snapshot := &xai.QuotaSnapshot{ + Requests: &xai.QuotaWindow{Limit: int64p(100), Remaining: int64p(30)}, // 70% used + Tokens: &xai.QuotaWindow{Limit: int64p(1000), Remaining: int64p(50), ResetUnix: &resetUnix}, // 95% used (most constrained) + } + + updates := buildGrokSchedulerExtraUpdates(snapshot) + require.NotNil(t, updates) + require.InDelta(t, 95.0, updates["grok_sched_utilization"], 0.001, "picks the most-constrained window") + require.Contains(t, updates, "grok_sched_reset_at") + + // The written extras must actually drive EvaluateAccountSchedulingThreshold + // (proves the previously-dead read side is now fed). + account := &Account{Platform: PlatformGrok, Extra: updates} + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, time.Now()) + require.True(t, decision.ShouldPause) + require.InDelta(t, 95.0, decision.UsedPercent, 0.001) + require.NotNil(t, decision.Until) +} + +func TestBuildGrokSchedulerExtraUpdates_NilWhenNoQuotaWindows(t *testing.T) { + require.Nil(t, buildGrokSchedulerExtraUpdates(&xai.QuotaSnapshot{})) + require.Nil(t, buildGrokSchedulerExtraUpdates(nil)) +} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 63a39826b0..2aa226dcd4 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -18,6 +18,7 @@ import ( "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" "go.uber.org/zap" ) @@ -33,6 +34,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + beginUpstreamResponseModelObservation(c) + // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 @@ -246,6 +249,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } } } + if account.Platform == PlatformOpenAI { + if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed { + responsesBody = policyBody + if responsesReq.Reasoning != nil { + responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String() + } + } + } // 4c. Apply OpenAI fast policy (may filter service_tier or block the request). // Mirrors the Claude anthropic-beta "fast-mode-2026-02-01" filter, but keyed @@ -294,7 +305,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) var upstreamReq *http.Request if account.Platform == PlatformGrok { - upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity, s.cfg) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) } else { upstreamReq, err = s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, isStream, promptCacheKey, false) } @@ -346,7 +357,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( break } upstreamCtxRetry, releaseRetry := detachUpstreamContext(ctx) - upstreamReq, err = buildGrokResponsesRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity, s.cfg) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtxRetry, c, account, responsesBody, token, grokCacheIdentity, s.cfg, s.settingService) releaseRetry() if err != nil { return nil, fmt.Errorf("build grok retry request: %w", err) @@ -540,6 +551,11 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event") return nil, fmt.Errorf("upstream stream ended without terminal event") } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + observer.Observe(finalResponse.Model, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) @@ -561,7 +577,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( } message := openAICompatFailedResponseMessage(finalResponse) if openAIStreamFailedEventShouldFailover(payload, message) { - return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message) + return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, resp.Header) } message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message) // 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致), @@ -592,16 +608,27 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( c.Header("Content-Type", "application/json; charset=utf-8") c.JSON(http.StatusOK, anthropicResp) - return &OpenAIForwardResult{ - RequestID: requestID, - ResponseID: finalResponse.ID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), - }, nil + result := &OpenAIForwardResult{ + RequestID: requestID, + ResponseID: finalResponse.ID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: false, + Duration: time.Since(startTime), + } + // Grok /v1/messages uses Responses upstream; count native search for surcharge. + if account != nil && account.IsGrok() && finalResponse != nil { + if body, err := json.Marshal(finalResponse); err == nil { + if n := countGrokNativeSearchCallsFromJSONBytes(body); n > 0 { + result.SearchCount = n + } + } + } + return result, nil } func isOpenAICompatResponsesTerminalEvent(eventType string) bool { @@ -821,6 +848,9 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( clientOutputStarted := false var streamFailoverErr error var streamNonFailoverErr error + searchCount := 0 + streamSearchSeen := make(map[string]struct{}) + countSearch := account != nil && account.IsGrok() scanner := s.newUpstreamSSEScanner(resp.Body) @@ -837,21 +867,31 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( if intervalTicker != nil { intervalCh = intervalTicker.C } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } // resultWithUsage builds the final result snapshot. resultWithUsage := func() *OpenAIForwardResult { - return &OpenAIForwardResult{ - RequestID: requestID, - ResponseID: responseID, - Usage: usage, - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: true, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - ClientDisconnect: clientDisconnected, + out := &OpenAIForwardResult{ + RequestID: requestID, + ResponseID: responseID, + Usage: usage, + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + Stream: true, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, } + if searchCount > 0 { + out.SearchCount = searchCount + } + return out } // processDataLine handles a single "data: ..." SSE line from upstream. @@ -861,6 +901,9 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms } + if countSearch { + searchCount += countGrokNativeSearchCallsInSSEDataDedup([]byte(payload), streamSearchSeen) + } var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { @@ -870,6 +913,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( ) return false } + observer.ObserveOpenAI([]byte(payload), event.Type) eventType := strings.TrimSpace(event.Type) isBareErrorEvent := eventType == "error" @@ -917,7 +961,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // 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) { - streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message) + streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header) return true } message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index c66077a0bf..e2929be92e 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -78,6 +79,14 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { chatBody = normalizedBody } + if account.Platform == PlatformOpenAI { + if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed { + chatBody = policyBody + if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" { + reasoningEffort = &effectiveEffort + } + } + } // Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody // is intentionally skipped: Anthropic Messages bodies carry no service_tier, // so the converted Chat Completions body never contains one and the policy diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go index 0ca6b5f106..dd5c1ed9c0 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -54,7 +54,16 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo mapped string effortJSON string wantEffort string + maxPolicy string }{ + { + name: "policy caps converted effort", + model: "gpt-5.6-luna", + mapped: "gpt-5.6-luna", + effortJSON: `,"output_config":{"effort":"max"}`, + wantEffort: "medium", + maxPolicy: "medium", + }, { name: "GPT56 max", model: "luna", @@ -103,7 +112,11 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo account.Credentials["model_mapping"] = map[string]any{tt.model: tt.mapped} svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, err := svc.ForwardAsAnthropic(context.Background(), c, account, []byte(body), "", "") + ctx := context.Background() + if tt.maxPolicy != "" { + ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil) + } + result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "") require.NoError(t, err) require.NotNil(t, result) require.Equal(t, tt.mapped, gjson.GetBytes(upstream.lastBody, "model").String()) @@ -420,7 +433,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsStreamReadErrorSkipsFinalize(t * func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":"high"},"stream":false}`) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) @@ -449,12 +462,16 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint( openai_compat.ExtraKeyResponsesSupported: true, } - result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil) + result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "") require.NoError(t, err) require.NotNil(t, result) require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/responses"), "responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String()) require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "medium", *result.ReasoningEffort) require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator")) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index cdf10772ee..8f9c296db8 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -17,6 +17,7 @@ import ( "time" "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" @@ -63,6 +64,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( }) return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason) } + if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() { + nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel)) + if setErr != nil { + return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr) + } + body = nextBody + } normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c)) if err != nil { @@ -270,17 +278,19 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } forwardResult := &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - ResponseID: responseID, - Usage: *usage, - Model: reqModel, - UpstreamModel: upstreamPassthroughModel, - ServiceTier: serviceTier, - ReasoningEffort: reasoningEffort, - Stream: reqStream, - OpenAIWSMode: false, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + ResponseID: responseID, + Usage: *usage, + Model: reqModel, + UpstreamModel: upstreamPassthroughModel, + UpstreamResponseModel: observedUpstreamResponseModel(c), + UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + ServiceTier: serviceTier, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + OpenAIWSMode: false, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, } if imageCount > 0 { forwardResult.ImageCount = imageCount @@ -382,6 +392,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // OAuth 透传到 ChatGPT internal API 时补齐必要头。 if account.Type == AccountTypeOAuth { + // 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. + stripOpenAILegacyResponsesBeta(req.Header) promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) req.Host = "chatgpt.com" if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil { @@ -402,11 +416,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } else if req.Header.Get("accept") == "" { req.Header.Set("accept", "text/event-stream") } - if req.Header.Get("OpenAI-Beta") == "" { - req.Header.Set("OpenAI-Beta", "responses=experimental") - } if req.Header.Get("originator") == "" { - req.Header.Set("originator", "codex_cli_rs") + req.Header.Set("originator", openai.CodexDefaultOriginator) } // 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。 if clientSessionID == "" { @@ -436,15 +447,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("user-agent", codexCLIUserAgent) } - // 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器 - // (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。 - s.overrideBrowserUserAgent(ctx, account, req) - - // 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为 - // 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为 - // codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。 + // 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份 + // (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。 if account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) } if req.Header.Get("content-type") == "" { @@ -453,10 +459,43 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + setOpenAICodexRoutingHintFromBody(req.Header, account, body) + logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http_passthrough", req.Header, body, "not_applicable") return req, nil } +func stripOpenAILegacyResponsesBeta(headers http.Header) { + if headers == nil { + return + } + + preserved := make([]string, 0) + for key, values := range headers { + if !strings.EqualFold(strings.TrimSpace(key), "OpenAI-Beta") { + continue + } + delete(headers, key) + for _, value := range values { + parts := strings.Split(value, ",") + kept := parts[:0] + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || strings.EqualFold(part, "responses=experimental") { + continue + } + kept = append(kept, part) + } + if len(kept) > 0 { + preserved = append(preserved, strings.Join(kept, ", ")) + } + } + } + for _, value := range preserved { + headers.Add("OpenAI-Beta", value) + } +} + func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool { if isOpenAIContextWindowError("", responseBody) { return false @@ -734,7 +773,13 @@ func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { if localStarted { return true } - return c != nil && c.Writer != nil && c.Writer.Written() + if c == nil || c.Writer == nil { + return false + } + // compact keepalive comments commit the HTTP response as 200, but they are + // not semantic model output and therefore must not block a safe retry. + // Without a compact keepalive this is equivalent to checking Writer.Size(). + return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0 } func openAIStreamEventIsPreamble(eventType string) bool { @@ -751,31 +796,94 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { if trimmed == "" { return false } - if strings.TrimSpace(eventType) == "response.failed" { + switch strings.TrimSpace(eventType) { + case "response.failed": return false + case "error": + // 上游降载/瞬时故障会先推 {"type":"error"} 帧、再以 response.failed 收尾。 + // 可重试类错误帧不能算客户端输出:一旦把它当首输出 flush, + // clientOutputStarted 即被固化,随后的 failed 事件永远进不了 pre-output + // failover 分支,只能把致命错误原样转发给客户端。不可重试类 + // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。 + payload := []byte(trimmed) + return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) } return !openAIStreamEventIsPreamble(eventType) } +// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写), +// 兼容 response.failed 的嵌套形态与裸 error 形态。 +func openAIStreamFailedEventErrorCode(payload []byte) string { + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) + if code == "" { + code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) + } + return code +} + +// isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。 +// 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error +// (code=server_is_overloaded / slow_down)并以 response.failed 收尾。 +func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { + switch openAIStreamFailedEventErrorCode(payload) { + case "server_is_overloaded", "slow_down": + return true + default: + return false + } +} + +// openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写 +// 使用的错误码。Codex CLI 按闭集对错误码分类:server_is_overloaded / slow_down +// 被判为致命错误(客户端提示 "Selected model is at capacity. Please try a +// different model." 并直接终止会话),而 server_error 等致命集之外的错误码会进入 +// 客户端内置的退避重试。 +const openAICapacityShedRetryableClientCode = "server_error" + +// sanitizeOpenAICapacityShedErrorCodeForClient 把即将写给下游客户端的 +// error / response.failed 事件中的容量降载错误码改写为客户端可重试的错误码。 +// 走到转发这一步说明网关侧 failover 已不可用(流中途)或已用尽;保留原始降载码 +// 只会让客户端就地终止会话。错误消息原样保留;监控与账号状态判定都基于改写前 +// 的原始 payload,不受影响。rate_limit 等其他错误码一律不动(客户端依赖 +// rate_limit_exceeded 原码解析重试延时)。 +func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) { + if len(payload) == 0 || !gjson.ValidBytes(payload) || !isOpenAIUpstreamCapacityShedEvent(payload) { + return payload, false + } + 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: + continue + } + next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode) + if err != nil { + return payload, false + } + updated = next + changed = true + } + return updated, changed +} + func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { if isOpenAIContextWindowError(message, payload) { return http.StatusBadRequest } - code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String())) - if code == "" { - code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String())) - } + code := openAIStreamFailedEventErrorCode(payload) errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String())) if errType == "" { errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String())) } combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message))) switch { - case strings.Contains(errType, "invalid_request"): - return http.StatusBadRequest case strings.Contains(combined, "rate_limit"): return http.StatusTooManyRequests + case strings.Contains(errType, "invalid_request"): + return http.StatusBadRequest case strings.Contains(combined, "authentication") || strings.Contains(combined, "unauthorized") || strings.Contains(combined, "invalid_api_key"): return http.StatusUnauthorized case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"): @@ -787,6 +895,19 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { } } +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 + } + return http.StatusBadGateway +} + func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte { if len(payload) == 0 || !gjson.ValidBytes(payload) { return payload @@ -863,6 +984,12 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool if isOpenAIContextWindowError(message, payload) { return false } + // 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 { + return true + } if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) { return true } @@ -895,6 +1022,25 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool return true } +func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { + if account == nil { + return false + } + // 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。 + // 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关), + // 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。 + // 因此先在同一账号上做有界重试,用尽后才按常规流程切号。 + if isOpenAIUpstreamCapacityShedEvent(payload) { + return true + } + if !account.IsPoolMode() { + return false + } + semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message) + return account.IsPoolModeRetryableStatus(semanticStatus) || + isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) +} + func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError( c *gin.Context, account *Account, @@ -908,6 +1054,7 @@ func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError( if message == "" { message = "OpenAI upstream response failed" } + statusCode := openAIStreamFailureStatus(payload, message) detail := "" if len(payload) > 0 && s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes @@ -917,10 +1064,10 @@ func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError( detail = truncateString(string(payload), maxBytes) } if c != nil { - setOpsUpstreamError(c, http.StatusBadGateway, message, detail) + setOpsUpstreamError(c, statusCode, message, detail) event := OpsUpstreamErrorEvent{ Platform: PlatformOpenAI, - UpstreamStatusCode: http.StatusBadGateway, + UpstreamStatusCode: statusCode, UpstreamRequestID: strings.TrimSpace(upstreamRequestID), Passthrough: passthrough, Kind: kind, @@ -944,21 +1091,37 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError( upstreamRequestID string, payload []byte, message string, + responseHeaders ...http.Header, ) *UpstreamFailoverError { message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) 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 决定。 message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message) + errType := "upstream_error" + if statusCode == http.StatusTooManyRequests { + errType = "rate_limit_error" + } body, _ := json.Marshal(gin.H{ "error": gin.H{ - "type": "upstream_error", + "type": errType, "message": message, }, }) return &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: body, + StatusCode: statusCode, + ResponseBody: body, + ResponseHeaders: headers, + RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message), + RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload), } } @@ -971,6 +1134,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( originalModel string, mappedModel string, ) (*openaiStreamingResultPassthrough, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) // SSE headers @@ -996,6 +1163,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( sawDone := false sawTerminalEvent := false sawFailedEvent := false + semanticOutputSeen := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -1051,6 +1219,8 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if data, ok := extractOpenAISSEDataLine(line); ok { dataBytes := []byte(data) trimmedData := strings.TrimSpace(data) + rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) + observer.ObserveOpenAI(dataBytes, rawEventType) if needModelReplace && strings.Contains(data, mappedModel) { line = s.replaceModelInSSELine(line, mappedModel, originalModel) if replacedData, replaced := extractOpenAISSEDataLine(line); replaced { @@ -1113,7 +1283,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { return resultWithUsage(), - s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage) + s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header) } } forceFlushFailedEvent = true @@ -1139,6 +1309,18 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( line = "data: " + string(sanitizedData) } lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType) + if lineStartsClientOutput && trimmedData != "[DONE]" && !openAIStreamEventTypeIsTerminal(eventType) { + semanticOutputSeen = true + } + // OpenAI Responses streams that terminate with an empty + // response.completed (no output, no usage, no error, nothing sent + // to the client) are silent upstream refusals: fail over instead of + // recording a successful 0/0 usage turn (issue #5009). + if (eventType == "response.completed" || eventType == "response.done") && + !sawFailedEvent && !semanticOutputSeen && !clientOutputStarted && + openAIResponsesCompletedEventIsEmpty(dataBytes, usage) { + return resultWithUsage(), newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID) + } if firstTokenMs == nil && lineStartsClientOutput && trimmedData != "[DONE]" { ms := int(time.Since(startTime).Milliseconds()) firstTokenMs = &ms @@ -1237,6 +1419,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if bodyHasSSEFraming(body) { + observeOpenAISSEBody(observer, string(body)) + } else { + observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String())) + } // Detect SSE responses from upstream and convert to JSON. // Some upstreams (e.g. other sub2api instances) may return SSE even when diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index d3ccf24c74..4ae09575e3 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -936,9 +936,10 @@ func TestOpenAIGatewayServiceRecordUsage_GeneratesRequestIDWhenAllSourcesMissing require.Equal(t, billingRepo.lastCmd.RequestID, usageRepo.lastLog.RequestID) } -func TestOpenAIGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_BillingErrorWritesUnsettledUsageLog(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{} - billingRepo := &openAIRecordUsageBillingRepoStub{err: errors.New("billing tx failed")} + billingErr := errors.New("billing tx failed") + billingRepo := &openAIRecordUsageBillingRepoStub{err: billingErr} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo, nil) @@ -958,9 +959,16 @@ func TestOpenAIGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testi Account: &Account{ID: 30048}, }) - require.Error(t, err) + require.ErrorIs(t, err, billingErr) require.Equal(t, 1, billingRepo.calls) - require.Equal(t, 0, usageRepo.calls) + require.Equal(t, 1, usageRepo.calls) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 8, usageRepo.lastLog.InputTokens) + require.Equal(t, 4, usageRepo.lastLog.OutputTokens) + require.Greater(t, usageRepo.lastLog.InputCost, 0.0) + require.Greater(t, usageRepo.lastLog.OutputCost, 0.0) + require.Greater(t, usageRepo.lastLog.TotalCost, 0.0) + require.Zero(t, usageRepo.lastLog.ActualCost) } func TestOpenAIGatewayServiceRecordUsage_UpdatesAPIKeyQuotaWhenConfigured(t *testing.T) { @@ -2033,11 +2041,12 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "video-request-123", - ResponseID: "video-request-123", - Model: "grok-imagine-video-1.5", - BillingModel: "grok-imagine-video-1.5", - ImageCount: 1, + RequestID: "video-request-123", + ResponseID: "video-request-123", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", + // Pure video completion clears ImageCount (handler contract). + ImageCount: 0, VideoCount: 1, VideoResolution: VideoBillingResolution480P, VideoDurationSeconds: 1, @@ -2065,7 +2074,7 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) require.Equal(t, "grok-imagine-video-1.5", usageRepo.lastLog.Model) - require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 0, usageRepo.lastLog.ImageCount) require.Nil(t, usageRepo.lastLog.ImageSize) require.InDelta(t, 0.08, usageRepo.lastLog.TotalCost, 1e-12) require.InDelta(t, 0.02, usageRepo.lastLog.ActualCost, 1e-12) @@ -2090,7 +2099,7 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing ResponseID: "video-default-rate-card", Model: "grok-imagine-video-1.5", BillingModel: "grok-imagine-video-1.5", - ImageCount: 1, + ImageCount: 0, VideoCount: 1, VideoResolution: VideoBillingResolution720P, Duration: time.Second, @@ -2114,7 +2123,7 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoUsesDefaultRateCard(t *testing // 结果未携带 duration 时按上游默认 8 秒计费:0.14 USD/s × 8s。 require.InDelta(t, 0.14*8, usageRepo.lastLog.TotalCost, 1e-12) require.InDelta(t, 0.14*8, usageRepo.lastLog.ActualCost, 1e-12) - require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 0, usageRepo.lastLog.ImageCount) require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) require.Equal(t, 1, usageRepo.lastLog.VideoCount) @@ -2178,7 +2187,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePri RequestID: "resp_grok_video_group_price", Model: "grok-imagine-video", BillingModel: "grok-imagine-video", - ImageCount: 1, + ImageCount: 0, VideoCount: 1, VideoResolution: VideoBillingResolution720P, VideoDurationSeconds: 1, @@ -2202,7 +2211,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePri require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) - require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 0, usageRepo.lastLog.ImageCount) require.Nil(t, usageRepo.lastLog.ImageSize) require.InDelta(t, 0.037, usageRepo.lastLog.TotalCost, 1e-12) require.InDelta(t, 0.037, usageRepo.lastLog.ActualCost, 1e-12) @@ -2210,6 +2219,53 @@ func TestOpenAIGatewayServiceRecordUsage_GroupVideoPriceOverridesChannelImagePri require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) } +func TestOpenAIGatewayServiceRecordUsage_GroupVideoModelPriceOverridesFlatAndChannelPrice(t *testing.T) { + groupID := int64(129) + channelPrice := 0.201 + flatVideoPrice720P := 0.037 + modelVideoPrice720P := 0.123 + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "grok-imagine-video-1.5-preview", channelPrice) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_video_model_price", + Model: "grok-imagine-video-1.5-preview", + BillingModel: "grok-imagine-video-1.5-preview", + ImageCount: 0, + VideoCount: 1, + VideoResolution: VideoBillingResolution720P, + VideoDurationSeconds: 2, + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10129, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + Platform: PlatformGrok, + RateMultiplier: 1, + VideoRateIndependent: true, + VideoRateMultiplier: 1, + VideoPrice720P: &flatVideoPrice720P, + VideoModelPrices: map[string]map[string]float64{ + VideoPriceFamilyGrokImagineVideo15: {VideoBillingResolution720P: modelVideoPrice720P}, + }, + }, + }, + User: &User{ID: 20129}, + Account: &Account{ID: 30129, Platform: PlatformGrok}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, modelVideoPrice720P*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, modelVideoPrice720P*2, usageRepo.lastLog.ActualCost, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeVideo), *usageRepo.lastLog.BillingMode) +} + func TestOpenAIGatewayServiceRecordUsage_HydratesGroupImagePriceWhenAuthSnapshotOmitsIt(t *testing.T) { groupID := int64(130) groupImagePrice2K := 0.021 @@ -2280,7 +2336,7 @@ func TestOpenAIGatewayServiceRecordUsage_HydratesGroupVideoPriceWhenAuthSnapshot RequestID: "resp_grok_video_hydrated_price", Model: "grok-imagine-video", BillingModel: "grok-imagine-video", - ImageCount: 1, + ImageCount: 0, VideoCount: 1, VideoResolution: VideoBillingResolution720P, VideoDurationSeconds: 1, @@ -2321,7 +2377,7 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoWithTokenChannelPricingKeepsVi RequestID: "resp_grok_video_token_channel", Model: "grok-imagine-video", BillingModel: "grok-imagine-video", - ImageCount: 1, + ImageCount: 0, VideoCount: 1, VideoResolution: VideoBillingResolution720P, VideoDurationSeconds: 5, @@ -2346,7 +2402,7 @@ func TestOpenAIGatewayServiceRecordUsage_GrokVideoWithTokenChannelPricingKeepsVi require.NotNil(t, usageRepo.lastLog.BillingMode) require.Equal(t, string(BillingModeToken), *usageRepo.lastLog.BillingMode) require.Nil(t, usageRepo.lastLog.ImageSize) - require.Equal(t, 1, usageRepo.lastLog.ImageCount) + require.Equal(t, 0, usageRepo.lastLog.ImageCount) require.Equal(t, 1, usageRepo.lastLog.VideoCount) require.NotNil(t, usageRepo.lastLog.VideoResolution) require.Equal(t, VideoBillingResolution720P, *usageRepo.lastLog.VideoResolution) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index fdd4019119..04b3e7e050 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -132,7 +132,14 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep } itemType, _ := inputItem["type"].(string) - if strings.TrimSpace(itemType) != "reasoning" { + switch strings.TrimSpace(itemType) { + case "compaction", "compaction_summary": + if _, encrypted := inputItem["encrypted_content"]; encrypted { + return nil, true, false + } + return item, false, true + case "reasoning": + default: return item, false, true } @@ -301,6 +308,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { "tools", "parallel_tool_calls", "reasoning", + "service_tier", "text", "previous_response_id", } { @@ -364,7 +372,27 @@ func resolveOpenAICompactSessionID(c *gin.Context) string { return uuid.NewString() } +// openAIResponsesRequestPathSuffix 返回可拼接到上游 /responses URL 后面的子路径。 +// 不可转发的子路径返回空串(退化为裸 /responses);真正的拒绝由入口守卫 +// IsForwardableOpenAIResponsesRequestPath 负责。这样即便将来新增路由漏挂守卫, +// 拼进上游 URL 的也只会是合规片段。 func openAIResponsesRequestPathSuffix(c *gin.Context) string { + suffix, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c)) + if !ok { + return "" + } + return suffix +} + +// IsForwardableOpenAIResponsesRequestPath 判断入站请求携带的 /responses 子路径 +// 是否可以安全转发。路由层用它在鉴权后、调度前直接拒绝畸形子路径。 +func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool { + _, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c)) + return ok +} + +// rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。 +func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string { if c == nil || c.Request == nil || c.Request.URL == nil { return "" } @@ -388,8 +416,9 @@ func openAIResponsesRequestPathSuffix(c *gin.Context) string { func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string { trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/") - trimmedSuffix := strings.TrimSpace(suffix) - if trimmedBase == "" || trimmedSuffix == "" { + // 兜底:调用方漏了校验时,这里也不会把不合规的片段拼进上游 URL。 + trimmedSuffix, ok := sanitizedUpstreamPathSuffix(suffix) + if !ok || trimmedBase == "" || trimmedSuffix == "" { return trimmedBase } return trimmedBase + trimmedSuffix @@ -756,14 +785,13 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo } func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { - model := strings.ToLower(strings.TrimSpace(reqModel)) - if !strings.Contains(model, "codex") { + if !isOpenAICodexModel(reqModel) { return "" } instructions := gjson.GetBytes(body, "instructions") if !instructions.Exists() { - return "instructions_missing" + return "" } if instructions.Type != gjson.String { return "instructions_not_string" @@ -774,6 +802,10 @@ func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byt return "" } +func isOpenAICodexModel(model string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex") +} + // extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel, // billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个 // 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的 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 ad14f4d8d8..dc3f2d719d 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -99,6 +99,47 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) { assert.False(t, changed) } +func TestTrimOpenAIEncryptedReasoningItems_Compaction(t *testing.T) { + tests := []struct { + name string + itemType string + encrypted bool + changed bool + }{ + {name: "compaction", itemType: "compaction", encrypted: true, changed: true}, + {name: "compaction summary", itemType: "compaction_summary", encrypted: true, changed: true}, + {name: "unencrypted compaction", itemType: "compaction", encrypted: false, changed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := map[string]any{"type": tt.itemType, "id": "cmp_stale"} + if tt.encrypted { + item["encrypted_content"] = "gAAA" + } + reqBody := map[string]any{"input": []any{ + item, + map[string]any{"type": "message", "content": "hi"}, + }} + + changed := trimOpenAIEncryptedReasoningItems(reqBody) + assert.Equal(t, tt.changed, changed) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.NotEmpty(t, input) + first, ok := input[0].(map[string]any) + require.True(t, ok) + if tt.changed { + require.Len(t, input, 1) + assert.Equal(t, "message", first["type"]) + return + } + require.Len(t, input, 2) + assert.Equal(t, tt.itemType, first["type"]) + }) + } +} + func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) { body := []byte(`{"model":"gpt-5.1","input":[` + `{"type":"message","role":"user","content":"hi"},` + diff --git a/backend/internal/service/openai_gateway_response_flush_test.go b/backend/internal/service/openai_gateway_response_flush_test.go index 2c35374596..ebfa0f22f1 100644 --- a/backend/internal/service/openai_gateway_response_flush_test.go +++ b/backend/internal/service/openai_gateway_response_flush_test.go @@ -387,13 +387,30 @@ func TestOpenAIResponseFlush_FailedAndErrorEventsFlushAtBoundaries(t *testing.T) require.Contains(t, flushes[1], "response.failed") }) - t.Run("error event", func(t *testing.T) { + t.Run("retryable error event buffered until terminal", func(t *testing.T) { + // 可重试类 error 帧不算客户端输出:保持在 attempt 缓冲中不单独 flush, + // 为随后可能到达的 response.failed 保留 pre-output failover 能力, + // 与终止帧一起出站。 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.NotNil(t, result) + gotBody, flushes := recorder.snapshot() + require.Equal(t, body, gotBody) + require.Len(t, flushes, 1) + }) + + t.Run("non-retryable error event flushes at boundary", func(t *testing.T) { + body := "data: {\"type\":\"error\",\"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.NoError(t, err) require.NotNil(t, result) gotBody, flushes := recorder.snapshot() diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 7c10724028..d8a17b19f4 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -28,6 +28,7 @@ type openaiStreamingResult struct { responseID string imageCount int imageOutputSizes []string + searchCount int } type openaiNonStreamingResult struct { @@ -36,6 +37,7 @@ type openaiNonStreamingResult struct { responseID string imageCount int imageOutputSizes []string + searchCount int } func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { @@ -43,6 +45,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp } func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel, reasoningEffort string) (*openaiStreamingResult, error) { + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } firstOutputTimeout := time.Duration(0) if account != nil && account.Platform == PlatformOpenAI { firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort) @@ -150,6 +156,16 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 { streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second } + // Grok: always enforce an upstream-read idle so hung SSE bodies fail over + // instead of holding the OAuth slot until the client cancels. Prefer the + // global gateway setting when set; otherwise apply a Grok-only default. + if account != nil && account.Platform == PlatformGrok { + cfgSec := 0 + if s.cfg != nil { + cfgSec = s.cfg.Gateway.StreamDataIntervalTimeout + } + streamInterval = resolveGrokStreamIdleTimeout(cfgSec) + } // 仅监控上游数据间隔超时,不被下游写入阻塞影响 var intervalTicker *time.Ticker if streamInterval > 0 { @@ -212,6 +228,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage sawTerminalEvent := false sawFailedEvent := false + responsesSemanticOutputSeen := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -288,6 +305,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() streamImageOutputs := make([]json.RawMessage, 0, 1) streamSeenImages := make(map[string]struct{}) + searchCounter := 0 + // Dedup search tool calls across SSE events (item.done + response.completed + // both list the same call_id — counting both would ~2× the surcharge). + streamSearchSeen := make(map[string]struct{}) resultWithUsage := func() *openaiStreamingResult { return &openaiStreamingResult{ usage: usage, @@ -295,6 +316,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. responseID: responseID, imageCount: imageCounter.Count(), imageOutputSizes: imageCounter.Sizes(), + searchCount: searchCounter, } } flushPending := func(disconnectMessage string) { @@ -406,6 +428,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. dataBytes := []byte(data) eventTypeRaw := gjson.GetBytes(dataBytes, "type").String() eventType := strings.TrimSpace(eventTypeRaw) + observer.ObserveOpenAI(dataBytes, eventTypeRaw) // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 if openAIStreamEventIsTerminalWithType(data, eventTypeRaw) { sawTerminalEvent = true @@ -449,7 +472,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { sawFailedEvent = true - streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage) + streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header) return } } @@ -462,6 +485,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. line = "data: " + data } imageCounter.AddSSEData(dataBytes) + searchCounter += countGrokNativeSearchCallsInSSEDataDedup(dataBytes, streamSearchSeen) // Correct Codex tool calls if needed (apply_patch -> edit, etc.) if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { @@ -519,6 +543,21 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if guardFirstOutput { eventStartsClientOutput = eventStartsClientOutput || startsClientOutput } + if startsClientOutput && !openAIStreamEventTypeIsTerminal(eventType) { + responsesSemanticOutputSeen = true + } + // OpenAI Responses streams that terminate with an empty + // response.completed (no output, no usage, no error, nothing sent + // to the client) are silent upstream refusals: fail over instead of + // recording a successful 0/0 usage turn (issue #5009). + if account != nil && account.Platform == PlatformOpenAI && + (eventType == "response.completed" || eventType == "response.done") && + !sawFailedEvent && !responsesSemanticOutputSeen && !clientOutputStarted && + openAIResponsesCompletedEventIsEmpty(dataBytes, usage) { + sawTerminalEvent = true + streamEarlyErr = newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID) + return + } // 写入客户端(客户端断开后继续 drain 上游) if !clientDisconnected { @@ -687,6 +726,16 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if s.rateLimitService != nil { s.rateLimitService.HandleStreamTimeout(ctx, account, originalModel) } + // Grok: short cool + account failover when no client-visible bytes + // were committed yet (pre-commit). After output started we keep the + // legacy stream_timeout path so partial SSE is not dual-written. + if account != nil && account.Platform == PlatformGrok { + s.tempUnscheduleGrok(ctx, account, grokStreamIdleCooldown, "grok stream idle timeout") + if !openAIStreamClientOutputStarted(c, clientOutputStarted) && !eventShouldFlush { + _ = resp.Body.Close() + return resultWithUsage(), grokStreamIdleFailoverError(account, streamInterval) + } + } sendErrorEvent("stream_timeout") return resultWithUsage(), fmt.Errorf("stream data interval timeout") @@ -990,6 +1039,32 @@ func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { return OpenAIUsage{}, false } +// openAIResponsesCompletedEventIsEmpty reports whether a response.completed / +// response.done SSE payload carries no usage, no error and no output items. +// The accumulated usage is consulted too, because OpenAI may deliver usage on +// an earlier event. An empty terminal event after a stream with no semantic +// output is treated as a silent upstream refusal (issue #5009). +func openAIResponsesCompletedEventIsEmpty(data []byte, usage *OpenAIUsage) bool { + if len(data) == 0 || !gjson.ValidBytes(data) { + return false + } + if usage != nil && (usage.InputTokens > 0 || usage.OutputTokens > 0 || + usage.ImageInputTokens > 0 || usage.ImageOutputTokens > 0 || + usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0) { + return false + } + if gjson.GetBytes(data, "usage").Exists() || gjson.GetBytes(data, "response.usage").Exists() { + return false + } + if gjson.GetBytes(data, "error").Exists() || gjson.GetBytes(data, "response.error").Exists() { + return false + } + if output := gjson.GetBytes(data, "response.output"); output.Exists() && output.IsArray() && len(output.Array()) > 0 { + return false + } + return true +} + func mergeHostedImageGenToolUsage(imageGen gjson.Result, usage *OpenAIUsage) { if !imageGen.Exists() || !imageGen.IsObject() { return @@ -1110,6 +1185,15 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r if err != nil { return nil, err } + observer := upstreamResponseModelObserverFromContext(c) + if observer == nil { + observer = beginUpstreamResponseModelObservation(c) + } + if bodyHasSSEFraming(body) { + observeOpenAISSEBody(observer, string(body)) + } else { + observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String())) + } // Detect SSE responses for ALL account types via Content-Type header. // Some OpenAI-compatible upstreams (including other sub2api instances) @@ -1180,6 +1264,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r responseID: extractOpenAIResponseIDFromJSONBytes(body), imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body), + searchCount: countGrokNativeSearchCallsFromJSONBytes(body), }, nil } @@ -1274,6 +1359,7 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte responseID: extractOpenAIResponseIDFromJSONBytes(body), imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText), + searchCount: countGrokNativeSearchCallsFromSSEBody(bodyText), }, nil } @@ -1310,10 +1396,21 @@ func extractOpenAISSEErrorMessage(payload []byte) string { } func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string, clientOutputStarted bool) ([]byte, bool) { - if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { + eventType = strings.TrimSpace(eventType) + isFailedEvent := eventType == "response.failed" + if (!isFailedEvent && eventType != "error") || len(payload) == 0 || !gjson.ValidBytes(payload) { return payload, false } updated := payload + // 容量降载码对 Codex CLI 是致命错误;事件既然要写给客户端(failover 已不可用), + // 就改写为客户端可重试的错误码。error 帧与 response.failed 都要改:上游降载 + // 总是先推 error 帧再收 failed,两帧携带同一个错误。 + if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(updated); changed { + updated = rewritten + } + if !isFailedEvent { + return updated, !bytes.Equal(updated, payload) + } if clientOutputStarted && isOpenAIContextWindowError(extractOpenAISSEErrorMessage(payload), payload) { errorPath := "" switch { diff --git a/backend/internal/service/openai_gateway_responses_empty_completed_test.go b/backend/internal/service/openai_gateway_responses_empty_completed_test.go new file mode 100644 index 0000000000..55bfda089c --- /dev/null +++ b/backend/internal/service/openai_gateway_responses_empty_completed_test.go @@ -0,0 +1,168 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestOpenAIResponsesEmptyCompletedFailsOver verifies that a Responses stream +// ending with an empty response.completed (no output, no usage, no error) is +// turned into a failover error instead of a successful empty reply (issue +// #5009). +func TestOpenAIResponsesEmptyCompletedFailsOver(t *testing.T) { + gin.SetMode(gin.TestMode) + + 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.created\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"completed\"}}\n\n", + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}] + }`) + + _, err := svc.Forward(context.Background(), c, account, body) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "empty completed must produce UpstreamFailoverError, got: %v", err) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Empty(t, recorder.Body.String(), "no empty success stream may reach the client") +} + +// TestOpenAIResponsesEmptyCompletedWithOutputSucceeds ensures streams with real +// semantic output are untouched. +func TestOpenAIResponsesEmptyCompletedWithOutputSucceeds(t *testing.T) { + gin.SetMode(gin.TestMode) + + 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.created\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n", + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, recorder.Body.String(), "hello") + require.NotNil(t, result.Usage) + require.Equal(t, 10, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) +} + +// TestOpenAIResponsesEmptyCompletedWithUsageSucceeds ensures a completed event +// carrying usage is not mistaken for a silent refusal even without output. +func TestOpenAIResponsesEmptyCompletedWithUsageSucceeds(t *testing.T) { + gin.SetMode(gin.TestMode) + + 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.created\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":0,\"total_tokens\":3}}}\n\n", + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + body := []byte(`{ + "model":"gpt-5.6-sol", + "stream":true, + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Usage) + require.Equal(t, 3, result.Usage.InputTokens) +} + +func TestOpenAIResponsesCompletedEventIsEmpty(t *testing.T) { + cases := []struct { + name string + data string + usage *OpenAIUsage + want bool + }{ + { + name: "bare completed", + data: `{"type":"response.completed"}`, + want: true, + }, + { + name: "completed with empty output array", + data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`, + want: true, + }, + { + name: "completed with usage", + data: `{"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}`, + want: false, + }, + { + name: "completed with error", + data: `{"type":"response.completed","response":{"id":"r1","status":"completed","error":{"code":"x"}}}`, + want: false, + }, + { + name: "completed with output item", + data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[{"type":"message","id":"msg_1"}]}}`, + want: false, + }, + { + name: "accumulated usage", + data: `{"type":"response.completed"}`, + usage: &OpenAIUsage{ + InputTokens: 7, + OutputTokens: 2, + }, + want: false, + }, + { + name: "invalid json", + data: `{"type":`, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, openAIResponsesCompletedEventIsEmpty([]byte(tc.data), tc.usage)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 01316cb58a..be25b72b01 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -74,6 +75,12 @@ func explicitOpenAISessionID(c *gin.Context, body []byte) string { // 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 // scheduling or upstream session behavior for non-Grok groups. +// +// For Grok groups only, previous_response_id is a last-resort sticky seed so +// multi-turn Responses chains stay on the same OAuth account when no explicit +// session/conversation/prompt_cache_key is present. Non-Grok groups omit this +// so HTTP OpenAI paths that delete previous_response_id before upstream are +// unchanged. func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string { if c == nil { return "" @@ -86,9 +93,27 @@ func explicitOpenAIRequestSessionID(c *gin.Context, body []byte) string { if sessionID == "" && len(body) > 0 { sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) } + if sessionID == "" && isGrokRequestContext(c) && len(body) > 0 { + sessionID = grokPreviousResponseSessionSeed(body) + } return sessionID } +// grokPreviousResponseSessionSeed returns a stable sticky seed from a Responses +// previous_response_id. Only resp_* response ids are accepted; message ids and +// unknown shapes must not pin sticky routing or prompt-cache identity. +func grokPreviousResponseSessionSeed(body []byte) string { + id := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) + if id == "" { + return "" + } + if ClassifyOpenAIPreviousResponseIDKind(id) != OpenAIPreviousResponseIDKindResponseID { + return "" + } + // Namespace so content-derived seeds never collide with response ids. + return "grok-prev-resp:" + id +} + // GenerateExplicitSessionHash generates a sticky-session hash only from explicit // client session signals. It intentionally skips content-derived fallback and is // used by stateless endpoints such as /v1/images. @@ -113,6 +138,13 @@ func (s *OpenAIGatewayService) GenerateExplicitSessionHash(c *gin.Context, body // 5. Header: x-grok-conv-id (Grok groups only) // 6. Body: prompt_cache_key // 7. Body: content-based fallback (model + system + tools + first user message) +// +// Grok sticky affinity is intentionally separate from the upstream +// prompt_cache_key identity (resolveGrokCacheIdentity): sticky pins an OAuth +// account for multi-turn routing, while the cache identity is tenant+model +// isolated for xAI server-side prompt cache. For Grok groups we scope the +// sticky seed with the client-requested model so switching models does not +// inherit a stale account binding (grok2api affinityKey pattern). func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) string { if c == nil { return "" @@ -126,11 +158,32 @@ func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) return "" } + if isGrokRequestContext(c) { + sessionID = grokStickyAffinitySeed(sessionID, body) + } + currentHash, legacyHash := deriveOpenAISessionHashes(sessionID) attachOpenAILegacySessionHashToGin(c, legacyHash) return currentHash } +// grokStickyAffinitySeed scopes sticky routing by model without changing the +// upstream prompt_cache_key written by applyGrokResponsesCacheIdentity. +func grokStickyAffinitySeed(sessionID string, body []byte) string { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return "" + } + model := "" + if len(body) > 0 { + model = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) + } + if model == "" { + return "grok-affinity:v1:" + sessionID + } + return "grok-affinity:v1:" + model + ":" + sessionID +} + // GenerateSessionHashWithFallback 先按常规信号生成会话哈希; // 当未携带 session_id/conversation_id/prompt_cache_key 时,使用 fallbackSeed 生成稳定哈希。 // 该方法用于 WS ingress,避免会话信号缺失时发生跨账号漂移。 @@ -157,7 +210,7 @@ func resolveOpenAIUpstreamOriginator(c *gin.Context, isOfficialClient bool) stri } } if isOfficialClient { - return "codex_cli_rs" + return openai.CodexDefaultOriginator } return "opencode" } @@ -259,6 +312,21 @@ 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 + } + // 分组利润控制:legacy 引擎的粘性/候选循环与 DB recheck 共用 + // 本判定,任何 fallback 都不能把利润不合格账号重新放回候选。 + if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { + return false + } + return true +} + +// 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 @@ -654,10 +722,10 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C // 3. 按优先级 + LRU 选择最佳账号 // Select by priority + LRU - selected, compactBlocked := s.selectBestAccount(ctx, groupID, platform, accounts, requestedModel, excludedIDs, requireCompact, requiredCapability, preferLowUpstreamRate) + selected, compactBlocked, filterStats := s.selectBestAccount(ctx, groupID, platform, accounts, requestedModel, excludedIDs, requireCompact, requiredCapability, preferLowUpstreamRate) if selected == nil { - return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked, "") + return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked, filterStats.summary("")) } hydrated, err := s.hydrateSelectedAccount(ctx, selected) @@ -665,9 +733,10 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C return nil, err } - // 4. 设置粘性会话绑定 - // Set sticky session binding - if sessionHash != "" { + // 4. 设置粘性会话绑定(利润门下推迟到 handler 终检通过后再绑定, + // 终检否决的账号不得成为新的粘性目标;无门保持既有 eager 绑定与 TTL) + // Set sticky session binding (deferred until terminal admission under a profit gate) + if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } @@ -746,10 +815,12 @@ 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). -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) { +// (only meaningful when requireCompact=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) compactBlocked := false + filterStats := openAISelectionFilterStats{pool: len(accounts)} needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) eligible := make([]*Account, 0, len(accounts)) compactTiers := make(map[int64]int, len(accounts)) @@ -760,18 +831,26 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i // 跳过被排除的账号 // Skip excluded accounts if _, excluded := excludedIDs[acc.ID]; excluded { + filterStats.exclude("excluded") continue } - fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, platform, requestedModel, false, requiredCapability) + fresh := s.resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx, acc, platform, requestedModel, false, requiredCapability) if fresh == nil { + filterStats.exclude("ineligible") continue } - fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, groupID, platform, requestedModel, false, requiredCapability) + fresh = s.recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx, fresh, groupID, platform, requestedModel, false, requiredCapability) if fresh == nil { + filterStats.exclude("ineligible") continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel, requireCompact) { + filterStats.exclude("channel_restricted") + continue + } + if vetoed, reason := openAIProfitControlVetoReason(ctx, fresh); vetoed { + filterStats.exclude(reason) continue } compactTier := 0 @@ -779,6 +858,7 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i compactTier = openAICompactSupportTier(fresh) if compactTier == 0 { compactBlocked = true + filterStats.exclude("compact_unsupported") continue } } @@ -788,7 +868,7 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i } if len(eligible) == 0 { - return nil, compactBlocked + return nil, compactBlocked, filterStats } rateOrder := openAILegacyUpstreamRateOrder{} if preferLowUpstreamRate { @@ -804,7 +884,7 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i } return s.isBetterAccount(a, b) }) - return eligible[0], compactBlocked + return eligible[0], compactBlocked, filterStats } // isBetterAccount 判断 candidate 是否比 current 更优。 @@ -842,7 +922,11 @@ 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) { - return s.selectAccountWithLoadAwareness(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, "", true) + ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + // 分组利润控制:legacy 公共入口同样装门,保证不经 + // selectAccountWithScheduler 的调用方也无法绕过利润准入。 + ctx = s.withOpenAIProfitControlGate(ctx, groupID) + return s.selectAccountWithLoadAwareness(ctx, groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, "", true) } 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) { @@ -1094,7 +1178,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if selectErr != nil { return nil, true, selectErr } - if sessionHash != "" { + if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, true, nil @@ -1133,7 +1217,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex if selectErr != nil { return nil, selectErr } - if sessionHash != "" { + if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } return selection, nil @@ -1195,7 +1279,14 @@ func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, grou platform = normalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, false) - return accounts, err + if err != nil { + return accounts, err + } + accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts) + if platform == PlatformGrok { + accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts) + } + return accounts, nil } var accounts []Account var err error @@ -1209,6 +1300,10 @@ func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, grou if err != nil { return nil, fmt.Errorf("query accounts failed: %w", err) } + accounts = s.filterOpenAIAccountsBySchedulingThreshold(ctx, accounts) + if platform == PlatformGrok { + accounts = s.filterGrokFreeQuotaAccountsForOpenAI(ctx, accounts) + } return accounts, nil } @@ -1220,6 +1315,17 @@ func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accoun } func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { + fresh := s.resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) + if fresh == nil { + return nil + } + if vetoed, _ := openAIProfitControlVetoReason(ctx, fresh); vetoed { + return nil + } + return fresh +} + +func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { if account == nil { return nil } @@ -1234,7 +1340,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context. fresh = current } - if !isOpenAICompatibleAccountEligibleForRequest(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { @@ -1243,7 +1349,10 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context. if s.isOpenAIAccountRequestRuntimeBlocked(fresh, requestedModel) { return nil } - if s.isOpenAIProxyStreamQuarantined(fresh) { + if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, fresh) { + return nil + } + if s.isOpenAIProxyStreamQuarantined(ctx, fresh) { return nil } return fresh @@ -1264,18 +1373,32 @@ func (s *OpenAIGatewayService) parentAccountLookup(ctx context.Context) func(int } func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, groupID *int64, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { + latest := s.recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx, account, groupID, platform, requestedModel, requireCompact, requiredCapability) + if latest == nil { + return nil + } + if vetoed, _ := openAIProfitControlVetoReason(ctx, latest); vetoed { + return nil + } + return latest +} + +func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx context.Context, account *Account, groupID *int64, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account { if account == nil { return nil } platform = normalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot == nil || s.accountRepo == nil { - if !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { + return nil + } + if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, account) { return nil } if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { return nil } - if s.isOpenAIProxyStreamQuarantined(account) { + if s.isOpenAIProxyStreamQuarantined(ctx, account) { return nil } return account @@ -1288,7 +1411,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if !s.openAIAccountMatchesSchedulingGroup(latest, groupID) { return nil } - if !isOpenAICompatibleAccountEligibleForRequest(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { @@ -1297,7 +1420,10 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) { return nil } - if s.isOpenAIProxyStreamQuarantined(latest) { + if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, latest) { + return nil + } + if s.isOpenAIProxyStreamQuarantined(ctx, latest) { return nil } return latest @@ -1323,9 +1449,49 @@ func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accoun if err != nil || account == nil { return account, err } + if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, account) { + return nil, nil + } + // Legacy sticky (advanced scheduler off) must still free-gate Grok OAuth. + if account.IsGrok() { + if gated := s.filterGrokFreeQuotaAccountsForOpenAI(ctx, []Account{*account}); len(gated) == 0 { + return nil, nil + } + } return account, nil } +// filterGrokFreeQuotaAccountsForOpenAI applies the same local free soft-gate as +// GatewayService / advanced scheduler, for OpenAI-compatible legacy selection. +func (s *OpenAIGatewayService) filterGrokFreeQuotaAccountsForOpenAI(ctx context.Context, accounts []Account) []Account { + if s == nil { + return accounts + } + return filterGrokFreeQuotaAccountsCore(ctx, s.cfg, s.usageLogRepo, &openaiGrokFreeQuotaGateCache, accounts) +} + +func (s *OpenAIGatewayService) filterOpenAIAccountsBySchedulingThreshold(ctx context.Context, accounts []Account) []Account { + if len(accounts) == 0 { + return accounts + } + + filtered := make([]Account, 0, len(accounts)) + for i := range accounts { + if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, &accounts[i]) { + continue + } + filtered = append(filtered, accounts[i]) + } + return filtered +} + +func (s *OpenAIGatewayService) isOpenAIAccountBlockedBySchedulingThreshold(ctx context.Context, account *Account) bool { + if s == nil || s.rateLimitService == nil || account == nil { + return false + } + return s.rateLimitService.ApplyAccountSchedulingThreshold(ctx, account) +} + func (s *OpenAIGatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { if account == nil || s.schedulerSnapshot == nil { return account, nil @@ -1345,12 +1511,12 @@ func (s *OpenAIGatewayService) newSelectionResult(ctx context.Context, account * if err != nil { return nil, err } - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: hydrated, Acquired: acquired, ReleaseFunc: release, WaitPlan: waitPlan, - }, nil + }), nil } func (s *OpenAIGatewayService) newAcquiredSelectionResult(ctx context.Context, account *Account, release func()) (*AccountSelectionResult, error) { diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go new file mode 100644 index 0000000000..fd3eefe4b4 --- /dev/null +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -0,0 +1,118 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { + t.Parallel() + + price := 10.0 // $10 / 1k searches → 100 searches = $1.0 + svc := &OpenAIGatewayService{ + billingService: newTestBillingService(), + } + apiKey := &APIKey{ + Group: &Group{ + SearchPricePer1k: &price, + }, + } + + // claude-sonnet-4 fallback: Input $3/MTok, Output $15/MTok + // 1000 in + 500 out → 0.003 + 0.0075 = 0.0105 + // + 100 searches → +1.0 → total 1.0105 + cost, err := svc.calculateOpenAIRecordUsageCost( + context.Background(), + &OpenAIForwardResult{SearchCount: 100}, + apiKey, + []string{"claude-sonnet-4"}, + 1.0, + 1.0, + 1.0, + 1.0, + UsageTokens{InputTokens: 1000, OutputTokens: 500}, + "", + false, + ) + require.NoError(t, err) + require.NotNil(t, cost) + require.InDelta(t, 1.0105, cost.ActualCost, 1e-9) + require.InDelta(t, 1.0105, cost.TotalCost, 1e-9) +} + +func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing.T) { + t.Parallel() + + price := 10.0 + svc := &OpenAIGatewayService{ + billingService: newTestBillingService(), + } + apiKey := &APIKey{ + Group: &Group{SearchPricePer1k: &price}, + } + // Empty model list: token path fails; search-only surcharge still bills. + cost, err := svc.calculateOpenAIRecordUsageCost( + context.Background(), + &OpenAIForwardResult{SearchCount: 100}, + apiKey, + nil, + 1.0, + 1.0, + 1.0, + 1.0, + UsageTokens{}, + "", + false, + ) + require.NoError(t, err) + require.NotNil(t, cost) + require.InDelta(t, 1.0, cost.ActualCost, 1e-9) +} + +func TestGroupMediaPricingLooksIncomplete_VideoModelPricesComplete(t *testing.T) { + t.Parallel() + require.True(t, groupMediaPricingLooksIncomplete(nil)) + require.True(t, groupMediaPricingLooksIncomplete(&Group{})) + require.False(t, groupMediaPricingLooksIncomplete(&Group{ + VideoModelPrices: map[string]map[string]float64{ + "grok-imagine-video": {"720p": 0.1}, + }, + })) + price := 10.0 + require.False(t, groupMediaPricingLooksIncomplete(&Group{SearchPricePer1k: &price})) + require.False(t, groupMediaPricingLooksIncomplete(&Group{AudioRealtimePricePerMin: &price})) + // Legacy video price alone still marks complete (existing path). + require.False(t, groupMediaPricingLooksIncomplete(&Group{VideoPrice720P: &price})) +} + +func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t *testing.T) { + t.Parallel() + + price := 10.0 + svc := &OpenAIGatewayService{ + billingService: newTestBillingService(), + } + apiKey := &APIKey{ + Group: &Group{SearchPricePer1k: &price}, + } + // Unknown model → token pricing fails; search must not replace that with $0/$search bill. + cost, err := svc.calculateOpenAIRecordUsageCost( + context.Background(), + &OpenAIForwardResult{SearchCount: 100}, + apiKey, + []string{"totally-unknown-model-xyz-no-pricing"}, + 1.0, + 1.0, + 1.0, + 1.0, + UsageTokens{InputTokens: 1000, OutputTokens: 500}, + "", + false, + ) + 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 c06a989908..55353e1d0a 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -33,10 +33,15 @@ const ( openaiPlatformAPIURL = "https://api.openai.com/v1/responses" openaiPlatformAPIInputTokensURL = "https://api.openai.com/v1/responses/input_tokens" openaiStickySessionTTL = time.Hour // 粘性会话TTL - // 与真实 Codex CLI 的 User-Agent 结构对齐: + // 与真实 Codex TUI 的 User-Agent 结构对齐: // {originator}/{version} ({OS} {OS_version}; {arch}) {terminal} - // 旧值 "codex_cli_rs/0.125.0" 缺少 OS/架构/终端后缀,易被上游指纹识别为非官方客户端。 - codexCLIUserAgent = "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color" + // 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。 + // 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。 + codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color" + // codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。 + // 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口, + // 各自硬编码会漂移成互相矛盾的身份。 + codexCLIUserAgent = openai.CodexDefaultOriginator + "/" + codexCLIVersion + codexCLIUserAgentSuffix // codex_cli_only 拒绝时单个请求头日志长度上限(字符) codexCLIOnlyHeaderValueMaxBytes = 256 @@ -51,7 +56,12 @@ const ( openAIWSRetryJitterRatioDefault = 0.2 openAICompactSessionSeedKey = "openai_compact_session_seed" openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint" - codexCLIVersion = "0.144.1" + // codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent + // 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载, + // 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出 + // 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本, + // 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。 + codexCLIVersion = "0.146.0" // Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。 openAICodexSnapshotPersistMinInterval = 30 * time.Second // 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。 @@ -233,6 +243,10 @@ type OpenAIForwardResult struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Empty when no mapping was applied (requested model was used as-is). UpstreamModel string + // UpstreamResponseModel is captured from the raw successful upstream + // response before any client-facing rewrite or protocol conversion. + UpstreamResponseModel string + UpstreamResponseModelConflict bool // 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 @@ -265,6 +279,10 @@ type OpenAIForwardResult struct { // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。 // 上游不返回 usage 字段,>0 时走按次计费(分组单价 × 次数 × 倍率)。 WebSearchCalls int + // SearchCount is Grok-native web_search / tool search call count (per 1k pricing). + SearchCount int + // AudioUsage carries Voice billing units when present. + AudioUsage *AudioUsage wsReplayInput []json.RawMessage wsReplayInputExists bool @@ -414,20 +432,21 @@ type OpenAIGatewayService struct { liveAttestation liveattestation.Provider liveAttestationCipher SecretEncryptor - openaiWSPoolOnce sync.Once - openaiWSStateStoreOnce sync.Once - openaiSchedulerOnce sync.Once - openaiProxyStreamCircuitOnce sync.Once - openaiWSPassthroughDialerOnce sync.Once - openaiModelTransientOnce sync.Once - agentIdentityTaskMu sync.Mutex - openaiWSPool *openAIWSConnPool - openaiWSStateStore OpenAIWSStateStore - openaiScheduler OpenAIAccountScheduler - openaiWSPassthroughDialer openAIWSClientDialer - openaiAccountStats *openAIAccountRuntimeStats - openaiModelTransient *openAIAccountModelTransientState - openaiProxyStreamCircuit *openAIProxyStreamCircuit + openaiWSPoolOnce sync.Once + openaiWSStateStoreOnce sync.Once + openaiSchedulerOnce sync.Once + openaiProxyStreamCircuitOnce sync.Once + openaiWSPassthroughDialerOnce sync.Once + openaiModelTransientOnce sync.Once + agentIdentityTaskMu sync.Mutex + openaiWSPool *openAIWSConnPool + openaiWSStateStore OpenAIWSStateStore + openaiScheduler OpenAIAccountScheduler + openaiWSPassthroughDialer openAIWSClientDialer + openaiAccountStats *openAIAccountRuntimeStats + openaiModelTransient *openAIAccountModelTransientState + openaiProxyStreamCircuit *openAIProxyStreamCircuit + openaiProxyStreamFailOpenLogAt atomic.Int64 openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time openaiAccountRuntimeBlockUntil sync.Map // key: int64(accountID), value: time.Time @@ -470,6 +489,11 @@ func NewOpenAIGatewayService( settingService *SettingService, userPlatformQuotaRepo UserPlatformQuotaRepository, ) *OpenAIGatewayService { + // enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点, + // 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。 + if cfg != nil { + SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement) + } svc := &OpenAIGatewayService{ accountRepo: accountRepo, usageLogRepo: usageLogRepo, 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 7eb133c125..846529828b 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 @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) type stubCodexRestrictionDetector struct { @@ -365,7 +366,12 @@ func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing _, err := svc.Forward(context.Background(), c, account, body) require.Error(t, err) - require.Equal(t, http.StatusBadGateway, rec.Code) + // missing_required_parameter 是确定性的请求错误:换账号、重试都不会变。按真实的 + // 400 回写并保留 param/code,客户端才知道该补哪个字段(而不是收到可重试的 502)。 + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String()) + require.Equal(t, "missing_required_parameter", gjson.Get(rec.Body.String(), "error.code").String()) + require.Equal(t, "instructions", gjson.Get(rec.Body.String(), "error.param").String()) require.Contains(t, err.Error(), "upstream error: 400") require.True(t, logSink.ContainsMessageAtLevel("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查", "warn")) diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index c8d72f0b81..a51f58bf56 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -593,6 +593,52 @@ func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDoesNotDecodeBeforeError( require.Equal(t, "summary_text", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.type").String()) } +func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), + }, + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 10, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + }, + Extra: map[string]any{"use_responses_api": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"id":"cmp_stale","type":"compaction","encrypted_content":"gAAA"},{"type":"message","content":[{"type":"input_text","text":"hi"}]}]}`) + 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, "compaction", gjson.GetBytes(upstream.bodies[0], "input.0.type").String()) + require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists()) +} + func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index d8cfba73b8..2c5bdba16b 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -31,6 +31,18 @@ type stubOpenAIAccountRepo struct { accounts []Account } +type tempUnschedulableOpenAIAccountRepo struct { + stubOpenAIAccountRepo + modelRateLimitAccountID int64 + modelRateLimitKey string +} + +func (r *tempUnschedulableOpenAIAccountRepo) SetModelRateLimit(_ context.Context, accountID int64, modelKey string, _ time.Time, _ ...string) error { + r.modelRateLimitAccountID = accountID + r.modelRateLimitKey = modelKey + return nil +} + type snapshotUpdateAccountRepo struct { stubOpenAIAccountRepo updateExtraCalls chan map[string]any @@ -56,6 +68,29 @@ func (r stubOpenAIAccountRepo) GetByID(ctx context.Context, id int64) (*Account, return nil, errors.New("account not found") } +func (r stubOpenAIAccountRepo) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + if len(ids) == 0 { + return []*Account{}, nil + } + index := make(map[int64]*Account, len(r.accounts)) + for i := range r.accounts { + account := &r.accounts[i] + index[account.ID] = account + } + out := make([]*Account, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if account, ok := index[id]; ok { + out = append(out, account) + } + } + return out, nil +} + func (r stubOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) { var result []Account for _, acc := range r.accounts { @@ -80,6 +115,113 @@ func (r stubOpenAIAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Co return r.ListSchedulableByPlatform(ctx, platform) } +func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailoverWithoutCommit(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + 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") + + upstreamBody := []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(upstreamBody)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.output_text.delta","delta":"ok"}`, + "", + `data: {"type":"response.completed","response":{"id":"resp_second","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n"))), + }, + }} + repo := &tempUnschedulableOpenAIAccountRepo{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{ + Enabled: false, AllowInsecureHTTP: true, + }}}, + httpUpstream: upstream, + rateLimitService: rateLimitService, + } + account := &Account{ + ID: 5099, Name: "temporary-unschedulable", Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "http://upstream.example", + "model_provider": "env-openai", + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{map[string]any{ + "error_code": float64(http.StatusBadRequest), + "keywords": []any{"our servers are currently overloaded", "please try again later"}, + "duration_minutes": float64(1), + }}, + }, + } + + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + + var failoverErr *UpstreamFailoverError + 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.False(t, IsResponseCommitted(c)) + require.Equal(t, http.StatusOK, rec.Code) + require.Empty(t, rec.Body.String()) + + secondRec := httptest.NewRecorder() + secondContext, _ := gin.CreateTestContext(secondRec) + secondContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + secondContext.Request.Header.Set("Content-Type", "application/json") + secondAccount := *account + secondAccount.ID = 5100 + secondAccount.Name = "healthy-failover-account" + result, secondErr := svc.ForwardAsAnthropic(context.Background(), secondContext, &secondAccount, body, "", "") + require.NoError(t, secondErr) + require.NotNil(t, result) + require.Equal(t, "resp_second", result.ResponseID) + require.NotEmpty(t, secondRec.Body.String()) +} + +func TestFailoverOpenAIUpstreamHTTPError_NilContextSkipsTempUnschedulablePolicy(t *testing.T) { + repo := &tempUnschedulableOpenAIAccountRepo{} + svc := &OpenAIGatewayService{ + rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil), + } + account := &Account{ + ID: 5099, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{map[string]any{ + "error_code": float64(http.StatusBadRequest), + "keywords": []any{"servers are currently overloaded"}, + "duration_minutes": float64(1), + }}, + }, + } + body := []byte(`{"error":{"message":"Our servers are currently overloaded."}}`) + 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", + ) + + require.Nil(t, got) + require.Zero(t, repo.modelRateLimitAccountID) + require.Empty(t, repo.modelRateLimitKey) +} + type groupAwareStubOpenAIAccountRepo struct { stubOpenAIAccountRepo } @@ -522,6 +664,20 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i return nil } +func (c *stubGatewayCache) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (c *stubGatewayCache) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (c *stubGatewayCache) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c *stubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) { now := time.Now() resetAt := now.Add(10 * time.Minute) @@ -1356,6 +1512,14 @@ func TestOpenAIStreamingPostOutputDisconnectQuarantinesSharedProxyWithoutSameStr svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ MaxLineSize: defaultMaxLineSize, }}} + // collapseInterval 0: the two loop iterations below record within the + // production collapse window and must count as distinct failure events here. + svc.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 2, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) for _, readErr := range []error{ io.ErrUnexpectedEOF, @@ -1404,7 +1568,7 @@ func TestOpenAIStreamingTerminalAndClientCancellationDoNotQuarantineProxy(t *tes Body: &openAIStreamReadThenErrorCloser{ reader: strings.NewReader(strings.Join([]string{ "event: response.completed", - `data: {"type":"response.completed","response":{"status":"completed","output":[]}}`, + `data: {"type":"response.completed","response":{"status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}`, "", }, "\n")), err: io.ErrUnexpectedEOF, @@ -1474,6 +1638,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputReturnsFailover(t *testing.T) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.False(t, failoverErr.RetryableOnSameAccount) require.Contains(t, string(failoverErr.ResponseBody), "An error occurred while processing your request") require.False(t, c.Writer.Written()) require.Empty(t, rec.Body.String()) @@ -1510,11 +1675,21 @@ func TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover(t Header: http.Header{"X-Request-Id": []string{"rid-capacity-failed"}}, } - _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model") + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Name: "pool-account", + Credentials: map[string]any{ + "pool_mode": true, + }, + } + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model") require.Error(t, err) var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.True(t, failoverErr.RetryableOnSameAccount) require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity") require.False(t, c.Writer.Written()) require.Empty(t, rec.Body.String()) @@ -1554,10 +1729,125 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) require.Contains(t, string(failoverErr.ResponseBody), "Please retry later") + // 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。 + // 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。 + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) require.False(t, c.Writer.Written()) require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingResponseFailedBeforeOutputRateLimitUsesPoolRetryPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + 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(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`, + "", + }, "\n"))), + Header: http.Header{ + "X-Request-Id": []string{"rid-rate-limit-failed"}, + "Retry-After": []string{"1"}, + }, + } + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Name: "pool-account", + Credentials: map[string]any{ + "pool_mode": true, + "pool_mode_retry_count": float64(1), + "pool_mode_retry_status_codes": []any{float64(http.StatusTooManyRequests)}, + }, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.True(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, "1", failoverErr.ResponseHeaders.Get("Retry-After")) + require.Equal(t, "rate_limit_error", gjson.GetBytes(failoverErr.ResponseBody, "error.type").String()) + require.Contains(t, string(failoverErr.ResponseBody), "Concurrency limit exceeded") + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) + + opsVal, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + opsEvents, ok := opsVal.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.NotEmpty(t, opsEvents) + require.Equal(t, http.StatusTooManyRequests, opsEvents[len(opsEvents)-1].UpstreamStatusCode) +} + +// 流内 rate limit 只产生 failover 错误,不写账号级限流/封禁状态: +// HTTP 200 流的 x-codex-* 头是正常配额快照,不能按 429 头驱动账号冷却。 +func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + 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(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1"}}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`, + "", + }, "\n"))), + Header: http.Header{ + "X-Codex-Primary-Used-Percent": []string{"12"}, + "X-Codex-Primary-Reset-After-Seconds": []string{"604800"}, + "Retry-After": []string{"1"}, + }, + } + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Name: "oauth-account", + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.False(t, failoverErr.RetryableOnSameAccount) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestOpenAIStreamingResponseFailedAfterOutputSanitizesVerboseResponseForClient(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{ @@ -1741,8 +2031,10 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) { cfg := &config.Config{ Gateway: config.GatewayConfig{ StreamDataIntervalTimeout: 0, - StreamKeepaliveInterval: 1, - MaxLineSize: defaultMaxLineSize, + // Keepalive is based on *downstream* idle time (last flush to client), + // not upstream event cadence. Interval is seconds (config unit). + StreamKeepaliveInterval: 1, + MaxLineSize: defaultMaxLineSize, }, } svc := &OpenAIGatewayService{cfg: cfg} @@ -1760,11 +2052,14 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) { go func() { defer func() { _ = pw.Close() }() + // Emit preamble/progress quickly so clientOutputStarted is true, then + // leave a real downstream idle gap longer than keepaliveInterval so the + // ticker can write ":\n\n". Frequent upstream ticks used to refresh + // lastDownstreamWriteAt and flake on loaded CI runners. _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n")) - for i := 0; i < 6; i++ { - time.Sleep(250 * time.Millisecond) - _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n")) - } + time.Sleep(50 * time.Millisecond) + _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n")) + time.Sleep(1300 * time.Millisecond) _, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n")) }() @@ -2008,6 +2303,14 @@ func TestOpenAIStreamingPassthroughPostOutputDisconnectQuarantinesSharedProxy(t proxyID := int64(4698) account := &Account{ID: 469804, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, ProxyID: &proxyID} svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + // collapseInterval 0: the loop below records within the production collapse + // window and must count as distinct failure events here. + svc.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 2, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) for _, readErr := range []error{io.ErrUnexpectedEOF, errors.New("http2: client connection lost")} { rec := httptest.NewRecorder() @@ -2029,7 +2332,7 @@ func TestOpenAIStreamingPassthroughPostOutputDisconnectQuarantinesSharedProxy(t require.Contains(t, rec.Body.String(), "partial") } - require.True(t, svc.isOpenAIProxyStreamQuarantined(account)) + require.True(t, svc.isOpenAIProxyStreamQuarantined(context.Background(), account)) } func TestOpenAIStreamingPassthroughResponseFailedBeforeOutputReturnsFailover(t *testing.T) { @@ -2627,10 +2930,30 @@ func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *test require.Equal(t, chatgptCodexURL+"/compact", req.URL.String()) require.Equal(t, "application/json", req.Header.Get("Accept")) require.Equal(t, codexCLIVersion, req.Header.Get("Version")) + require.Empty(t, req.Header.Get("OpenAI-Beta"), "Codex OAuth HTTP must not synthesize the legacy responses beta header") require.NotEmpty(t, req.Header.Get("Session_Id")) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(req.Context())) } +func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesExplicitAPIKeyBetaHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(`{"model":"gpt-5"}`))) + c.Request.Header.Set("OpenAI-Beta", "api-key-specific-beta") + + svc := &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + req, err := svc.buildUpstreamRequestOpenAIPassthrough(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token") + require.NoError(t, err) + require.Equal(t, "api-key-specific-beta", req.Header.Get("OpenAI-Beta"), "OAuth-only backport must not alter API-key passthrough headers") +} + func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -2648,6 +2971,7 @@ func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T) require.Equal(t, chatgptCodexURL+"/compact", req.URL.String()) require.Equal(t, "application/json", req.Header.Get("Accept")) require.Equal(t, codexCLIVersion, req.Header.Get("Version")) + require.Empty(t, req.Header.Get("OpenAI-Beta"), "Codex OAuth HTTP must not synthesize the legacy responses beta header") require.NotEmpty(t, req.Header.Get("Session_Id")) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(req.Context())) } @@ -2726,25 +3050,26 @@ func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) { func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) - // 上游要求 originator 与最终 User-Agent 首段配套(issue #3901): - // originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。 + // 强制统一出口:客户端自报的 originator / User-Agent 都不参与上游身份构造, + // 一律改写为网关规范身份,天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。 tests := []struct { - name string - userAgent string - originator string - wantOriginator string - wantUA string + name string + userAgent string + originator string }{ - {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, + {name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"}, { - name: "mismatched originator repaired from ua", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", - originator: "codex_cli_rs", - wantOriginator: "codex-tui", - wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + name: "mismatched originator", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + originator: "codex_cli_rs", }, - {name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, - {name: "third-party ua masked to default identity", userAgent: "luna/1.2.0", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, + { + name: "tui identity", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + originator: "codex-tui", + }, + {name: "official originator without ua", originator: "codex_vscode"}, + {name: "third-party ua", userAgent: "luna/1.2.0"}, } for _, tt := range tests { @@ -2768,8 +3093,9 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI) require.NoError(t, err) - require.Equal(t, tt.wantOriginator, req.Header.Get("originator")) - require.Equal(t, tt.wantUA, req.Header.Get("User-Agent")) + require.Equal(t, openai.CodexDefaultOriginator, req.Header.Get("originator")) + require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent")) + require.Equal(t, codexCLIVersion, req.Header.Get("version")) }) } } @@ -3193,6 +3519,32 @@ func TestHandleNonStreamingResponse_OAuthJSONBodyWithDataEventTextKeepsJSONUsage require.Contains(t, rec.Body.String(), "processing data: 1,2,3 then event: click finished") } +func TestHandleNonStreamingResponse_ObservesUpstreamModelBeforeClientRewrite(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_model_audit","object":"response","model":"gpt-5.5","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}`, + )), + } + account := &Account{ID: 1, Type: AccountTypeAPIKey} + + result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "gpt-5.6-sol", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + + // 客户端仍看到自己请求的模型名,审计观察器则保留改写前的上游声明。 + require.Equal(t, "gpt-5.6-sol", gjson.Get(rec.Body.String(), "model").String()) + require.Equal(t, "gpt-5.5", observedUpstreamResponseModel(c)) + require.False(t, observedUpstreamResponseModelConflict(c)) +} + func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index f56246e120..00a62f3f3e 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -211,7 +211,7 @@ func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool { switch statusCode { - case 401, 402, 403, 429, 529: + case 401, 402, 403, 405, 429, 529: return true default: return statusCode >= 500 @@ -488,6 +488,24 @@ func (s *OpenAIGatewayService) handleErrorResponse( MarkResponseCommitted(c) + // 上游 400 是确定性的请求错误:同一份请求体换账号、重试多少次都会失败。归一成 + // 502 upstream_error 会让下游网关把它当成可重试的上游故障反复重放(#5479 实测 + // 30 个失败请求被放大成 60 次上游调用),同时抹掉客户端定位问题所需的 code/param。 + // + // 走到这里说明 shouldFailoverOpenAIUpstreamResponse 已判定该 400 不可 failover, + // 即 server_is_overloaded / at capacity 这类可重试的 400 不会到达此处。 + // + // 兄弟路径早已这么做:handleCompatErrorResponse(ChatCompletions / Anthropic) + // 回真实状态码 + invalid_request_error + 真实 message;/v1/images 还额外透传 + // code/param。原生 Responses 是唯一漏掉的一条。 + if isOpenAIDeterministicClientError(resp.StatusCode) { + writeOpenAIUpstreamClientError(c, resp.StatusCode, body, upstreamMsg) + if upstreamMsg == "" { + return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) + } + return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + } + // Return appropriate error response var errType, errMsg string var statusCode int diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 1244698333..c2de3c6472 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -33,6 +33,10 @@ type OpenAIRecordUsageInput struct { RequestPayloadHash string APIKeyService APIKeyQuotaUpdater QuotaPlatform string // user×platform quota platform resolved by the handler before async billing. + // PricingAt 是请求级定价时刻(请求开始捕获,与利润门的 D 同源):高峰因子 + // 按该时刻计算,保证同一请求从准入到扣费不中途变价。零值回退记录时刻 + //(既有行为),供未装配的路径(图片/异步/cyber 等)沿用。 + PricingAt time.Time // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。 CyberBlocked bool ChannelUsageFields @@ -113,6 +117,15 @@ func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Contex return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) } +// openAIUsagePricingAt 返回本次用量记录使用的定价时刻:优先请求级 PricingAt +// (与利润门 D 同源同刻),未装配时回退记录时刻(既有行为)。 +func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { + if input != nil && !input.PricingAt.IsZero() { + return input.PricingAt + } + return timezone.Now() +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -159,10 +172,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, - // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。 + // 高峰因子按请求级 PricingAt 现算(与利润门 D 同源同刻,跨峰谷请求不中途 + // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的 + // Resolve,以免污染 user:group 倍率缓存。 baseMultiplier := multiplier - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, timezone.Now()) + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, openAIUsagePricingAt(input)) videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier) var cost *CostBreakdown @@ -225,6 +240,36 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ).Warn("openai_usage.pricing_missing_record_zero_cost", zap.Error(err)) cost = &CostBreakdown{BillingMode: string(BillingModeToken)} } + // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 + // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedOpenAIResponsePricing + // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 + // 既有行为。响应模型与基线同名时直接跳过:重算必然同价,白跑一次定价解析。 + baselineBillingModel := firstUsageBillingModel(billingModels) + if responseModel := responseModelBillingDeclaration( + input.BillingModelSource, + result.UpstreamResponseModel, + result.UpstreamResponseModelConflict, + result.ImageCount > 0 || result.VideoCount > 0 || result.WebSearchCalls > 0 || + result.AudioUsage != nil || result.SearchCount > 0, + ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { + if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { + responseModels := usageBillingModelCandidates(responseModel) + responseCost, responseErr := s.calculateOpenAIRecordUsageCost( + ctx, result, apiKey, responseModels, multiplier, imageMultiplier, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, + ) + // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost + // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 + // 定价基准(有渠道价就一定能算出价,循环不会落到后续候选)。 + baselineChannelPriced := s.resolveOpenAIChannelPricing(ctx, baselineBillingModel, apiKey) != nil + if responseErr == nil && responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { + logResponseModelBillingApplied("service.openai_gateway", account, result.RequestID, + baselineBillingModel, responseModel, cost, responseCost) + billingModels = responseModels + cost = responseCost + } + } + } // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -242,37 +287,61 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec requestID = upstreamRequestID } } + // Async Grok video: always use the stable task id for dedup (status + content polls + // share one bill). Context-local client/local IDs would otherwise create a new row + // per poll if Redis claim is lost. + if result.VideoCount > 0 { + if stable := StableGrokVideoBillingRequestID(firstNonEmpty( + strings.TrimPrefix(strings.TrimSpace(result.RequestID), "grok-video:"), + strings.TrimSpace(result.ResponseID), + strings.TrimPrefix(strings.TrimSpace(requestID), "grok-video:"), + )); stable != "" { + requestID = stable + } + } // 确定 RequestedModel(渠道映射前的原始模型) requestedModel := result.Model if input.OriginalModel != "" { requestedModel = input.OriginalModel } + sentModel := upstreamSentModel(result.Model, result.UpstreamModel) + if result.UpstreamResponseModelConflict { + logger.L().Warn("upstream_response_model_conflict", + zap.String("platform", account.Platform), + zap.Int64("account_id", account.ID), + zap.String("request_id", requestID), + zap.String("sent_model", sentModel), + zap.String("selected_response_model", strings.TrimSpace(result.UpstreamResponseModel)), + ) + } usageLog := &UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, - RequestID: requestID, - Model: result.Model, - RequestedModel: requestedModel, - UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), - ServiceTier: result.ServiceTier, - ReasoningEffort: result.ReasoningEffort, - InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), - UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), - InputTokens: actualInputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - ImageInputTokens: result.Usage.ImageInputTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - ImageCount: result.ImageCount, - ImageSize: optionalTrimmedStringPtr(result.ImageSize), - ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), - ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), - ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), - ImageSizeBreakdown: result.ImageSizeBreakdown, + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: requestID, + Model: result.Model, + RequestedModel: requestedModel, + 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), + InputTokens: actualInputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageInputTokens: result.Usage.ImageInputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + ImageCount: result.ImageCount, + ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) if isVideoUsage { @@ -385,6 +454,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec }() if billingErr != nil { + usageLog.ActualCost = 0 + writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway") return billingErr } writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.openai_gateway") @@ -392,6 +463,23 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } +// hasIdentifiedOpenAIResponsePricing 判断上游自报的响应模型是否可以作为计费基准, +// 并回传它是否解析到了渠道级定价(供 responseModelBillingAdoptable 的跨定价源守卫使用, +// 避免为此再解析一次)。 +// 只接受管理员为该模型显式配置的渠道定价,或价格表中能被确定性识别的条目; +// 刻意不接受按子串猜出来的系列兜底价,否则上游随便编一个含 "haiku" 的名字就能把 +// 计费拉到最便宜的系列价上。详见 responseModelBillingDeclaration。 +func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Context, model string, apiKey *APIKey) (identified bool, channelPriced bool) { + model = strings.TrimSpace(model) + if model == "" { + return false, false + } + if s.resolveOpenAIChannelPricing(ctx, model, apiKey) != nil { + return true, true + } + return s.billingService.HasIdentifiedTokenPricing(model), false +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, @@ -418,39 +506,83 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( return s.calculateOpenAIVideoCost(ctx, billingModel, apiKey, result, videoMultiplier), nil } } + if result != nil && result.AudioUsage != nil { + cfg := groupAudioPriceConfigFromAPIKey(apiKey) + return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, webSearchMultiplier), nil + } + if result != nil && result.ImageCount > 0 { // 渠道定价为 token 计费时走 token 路径,否则走图片计费 if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved == nil || resolved.Mode != BillingModeToken { return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil } } - if len(billingModels) == 0 || billingModel == "" { - return nil, errors.New("openai usage billing model is empty") - } + + // Token path (optional search surcharge is additive — never replaces token cost). + var tokenCost *CostBreakdown var lastErr error - for _, candidate := range billingModels { - candidate = strings.TrimSpace(candidate) - if candidate == "" { - continue + if len(billingModels) > 0 && billingModel != "" { + for _, candidate := range billingModels { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + cost, err := s.calculateOpenAIRecordUsageTokenCost( + ctx, + apiKey, + candidate, + multiplier, + tokens, + serviceTier, + longContextBillingEnabled, + ) + if err == nil { + tokenCost = cost + break + } + lastErr = err } - cost, err := s.calculateOpenAIRecordUsageTokenCost( - ctx, - apiKey, - candidate, - multiplier, - tokens, - serviceTier, - longContextBillingEnabled, - ) - if err == nil { - return cost, nil + } + // Search surcharge is additive. Never let a zero/default search cost mask a + // real token-pricing failure for requests that attempted token billing. + searchCost := (*CostBreakdown)(nil) + if result != nil && result.SearchCount > 0 { + price := groupSearchPricePer1kFromAPIKey(apiKey) + if price != nil && *price == 0 { + logger.L().Info("openai_usage.search_price_per_1k_explicit_free", + zap.Int("search_count", result.SearchCount), + zap.String("model", billingModel), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) } - lastErr = err + searchCost = s.billingService.CalculateSearchCost(result.SearchCount, price, webSearchMultiplier) } - if lastErr == nil { - lastErr = errors.New("no non-empty billing model candidates") + + tokenBillingAttempted := len(billingModels) > 0 && billingModel != "" + if tokenCost == nil { + if tokenBillingAttempted { + if lastErr == nil { + lastErr = errors.New("no non-empty billing model candidates") + } + return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) + } + // Search-only (no model / pure tool path): allow search billing alone. + if searchCost != nil { + return searchCost, nil + } + if lastErr == nil { + lastErr = errors.New("openai usage billing model is empty") + } + return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) } - return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) + if searchCost == nil || (searchCost.TotalCost == 0 && searchCost.ActualCost == 0) { + return tokenCost, nil + } + // Additive: tokens + search surcharge. + tokenCost.TotalCost += searchCost.TotalCost + tokenCost.ActualCost += searchCost.ActualCost + return tokenCost, nil } func isGrokVideoBillingModel(model string) bool { @@ -461,6 +593,8 @@ func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) if result == nil || result.VideoCount <= 0 { return false } + // VideoCount alone is authoritative for async video completion billing. + // Prefer model-family match when present; never drop video mode on rename/mapping. candidates := append([]string{}, billingModels...) candidates = append(candidates, result.BillingModel, result.Model, result.UpstreamModel) for _, candidate := range candidates { @@ -468,7 +602,7 @@ func isGrokVideoUsageResult(result *OpenAIForwardResult, billingModels []string) return true } } - return false + return true } func isUsagePricingUnavailableError(err error) bool { @@ -569,13 +703,13 @@ func (s *OpenAIGatewayService) calculateOpenAIVideoCost( resolution := NormalizeVideoBillingResolutionOrDefault(result.VideoResolution) durationSeconds := NormalizeVideoBillingDurationSecondsOrDefault(result.VideoDurationSeconds) groupConfig := videoPriceConfigFromAPIKey(apiKey) - if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { + if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, resolution) { return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } if refreshed := s.apiKeyWithFreshGroupMediaPricing(ctx, apiKey); refreshed != apiKey { apiKey = refreshed groupConfig = videoPriceConfigFromAPIKey(apiKey) - if apiKeyHasConfiguredVideoPrice(apiKey, resolution) { + if apiKeyHasConfiguredVideoPrice(apiKey, billingModel, resolution) { return s.billingService.CalculateVideoCost(billingModel, resolution, videoCount, durationSeconds, groupConfig, multiplier) } } @@ -622,10 +756,14 @@ func (s *OpenAIGatewayService) apiKeyWithFreshGroupMediaPricing(ctx context.Cont return &clone } -// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体计费字段(例如由不含 -// 这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在数据库中的 -// 默认值均为 1.0,正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、全部媒体 -// 价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条媒体用量都多打一次 DB 查询。 +// groupMediaPricingLooksIncomplete 判断分组对象是否可能缺失媒体/搜索/语音计费字段 +// (例如由不含这些字段的旧快照或手工构造的上下文对象生成)。image/video 独立倍率在 +// 数据库中的默认值均为 1.0;正常加载的分组不可能两个倍率同时为 0 且未开启独立倍率、 +// 全部媒体/搜索/语音价为 nil——只有这种情况才回源查库,避免对未配置覆盖价的分组每条 +// 用量都多打一次 DB 查询。 +// +// 注意:apiKeyAuthSnapshotVersion 升级会强制刷新存量快照;本函数是热路径上的二次兜底, +// 不能仅凭 legacy video_price_* 判定完整而跳过 VideoModelPrices/search/audio 的回源。 func groupMediaPricingLooksIncomplete(group *Group) bool { if group == nil { return true @@ -636,6 +774,17 @@ func groupMediaPricingLooksIncomplete(group *Group) bool { if group.ImageRateMultiplier != 0 || group.VideoRateMultiplier != 0 { return false } + // Any first-class pricing field present means the projection is not a blank shell. + if len(group.VideoModelPrices) > 0 { + return false + } + if group.SearchPricePer1k != nil || + group.AudioRealtimePricePerMin != nil || + group.AudioTTSPricePerMillionChars != nil || + group.AudioSTTPricePerHour != nil || + group.WebSearchPricePerCall != nil { + return false + } return group.ImagePrice1K == nil && group.ImagePrice2K == nil && group.ImagePrice4K == nil && group.VideoPrice480P == nil && group.VideoPrice720P == nil && group.VideoPrice1080P == nil } diff --git a/backend/internal/service/openai_gateway_usage_integrity.go b/backend/internal/service/openai_gateway_usage_integrity.go new file mode 100644 index 0000000000..e0344d4f44 --- /dev/null +++ b/backend/internal/service/openai_gateway_usage_integrity.go @@ -0,0 +1,81 @@ +package service + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + grokMissingUsageErrorCode = "grok_missing_usage" + grokMissingUsageMessage = "xAI upstream returned a successful chat completion without billable usage" +) + +// hasBillableGrokChatUsage stays aligned with the aggregate token buckets used +// to account for chat completions. Detail fields alone do not prove that the +// successful response can be settled safely. +func hasBillableGrokChatUsage(usage OpenAIUsage) bool { + return usage.InputTokens > 0 || + usage.OutputTokens > 0 || + usage.CacheCreationInputTokens > 0 || + usage.CacheReadInputTokens > 0 +} + +// requiresBillableGrokChatUsage identifies Grok traffic by both account +// platform and model identity. Grok models may be served through generic +// OpenAI-compatible accounts, so account.Platform alone is not a safe billing +// boundary. +func requiresBillableGrokChatUsage(account *Account, models ...string) bool { + if account != nil && account.Platform == PlatformGrok { + return true + } + for _, model := range models { + normalized := strings.ToLower(strings.TrimSpace(model)) + if separator := strings.LastIndex(normalized, "/"); separator >= 0 { + normalized = strings.TrimSpace(normalized[separator+1:]) + } + if normalized == "grok" || strings.HasPrefix(normalized, "grok-") { + return true + } + } + return false +} + +func newGrokMissingUsageFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError { + accountID := int64(0) + accountName := "" + if account != nil { + accountID = account.ID + accountName = account.Name + } + + setOpsUpstreamError(c, http.StatusBadGateway, grokMissingUsageMessage, "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: PlatformGrok, + AccountID: accountID, + AccountName: accountName, + UpstreamStatusCode: http.StatusBadGateway, + UpstreamRequestID: strings.TrimSpace(upstreamRequestID), + Kind: "failover", + Message: grokMissingUsageMessage, + }) + + body, _ := json.Marshal(gin.H{ + "error": gin.H{ + "type": "upstream_error", + "code": grokMissingUsageErrorCode, + "message": grokMissingUsageMessage, + }, + }) + headers := http.Header{} + if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" { + headers.Set("x-request-id", requestID) + } + return &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: body, + ResponseHeaders: headers, + } +} diff --git a/backend/internal/service/openai_gateway_usage_integrity_test.go b/backend/internal/service/openai_gateway_usage_integrity_test.go new file mode 100644 index 0000000000..3a1dc441f9 --- /dev/null +++ b/backend/internal/service/openai_gateway_usage_integrity_test.go @@ -0,0 +1,58 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRequiresBillableGrokChatUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + account *Account + models []string + want bool + }{ + { + name: "grok platform", + account: &Account{Platform: PlatformGrok}, + models: []string{"alias"}, + want: true, + }, + { + name: "OpenAI-compatible requested Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"grok-4.5"}, + want: true, + }, + { + name: "OpenAI-compatible mapped Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"alias", "grok-4.5"}, + want: true, + }, + { + name: "xAI-qualified Grok model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"xai/grok-4.5"}, + want: true, + }, + { + name: "ordinary OpenAI model", + account: &Account{Platform: PlatformOpenAI}, + models: []string{"gpt-5.4"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, requiresBillableGrokChatUsage(tt.account, tt.models...)) + }) + } +} diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index f32fc56159..edd3fc92fc 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -601,7 +601,12 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if err != nil { return nil, err } - upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, parsed.Stream) + // 生图是长耗时、上游侧已产生实际成本的操作:客户端中途断开不应连带取消上游请求。 + // detachStreamUpstreamContext 在非流式时原样返回请求 context,于是客户端一断开 + // 就把已经在出图的上游调用打断成 context canceled,网关记 502、不扣费,而上游那边 + // 图已经生成并计费。同一端点的 OAuth 分支 forwardOpenAIImagesOAuth 以及 Grok 媒体 + // 路径本来就无条件脱钩,这里对齐;上游侧仍由 ResponseHeaderTimeout 兜底。 + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) defer releaseUpstreamCtx() token, _, err := s.GetAccessToken(upstreamCtx, account) diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 82f9c3addc..e3edbaa5ae 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -767,7 +767,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.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Empty(t, 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()) diff --git a/backend/internal/service/openai_images_upstream_context_test.go b/backend/internal/service/openai_images_upstream_context_test.go new file mode 100644 index 0000000000..658248b6aa --- /dev/null +++ b/backend/internal/service/openai_images_upstream_context_test.go @@ -0,0 +1,154 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +func newOpenAIImagesTestContext(t *testing.T, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + 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 + return c, rec +} + +func newOpenAIImagesTestService(upstream HTTPUpstream) *OpenAIGatewayService { + return &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }, + } +} + +func newOpenAIImagesAPIKeyAccount() *Account { + return &Account{ + ID: 31, + Name: "openai-apikey-images", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } +} + +func openAIImagesJSONResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_ctx"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"created":1710000000,"data":[{"b64_json":"aGVsbG8="}],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}`, + )), + } +} + +// issue #5411:生图是长耗时、上游侧已经产生实际成本的操作。客户端中途断开时, +// 如果连带取消上游请求,就会出现「上游已出图并计费、网关记 502 context canceled、 +// 用户不扣费」。非流式路径以前走 detachStreamUpstreamContext(ctx, false), +// 该函数在非流式时原样返回请求 context,因此不脱钩。 +func TestForwardOpenAIImagesAPIKey_NonStreamDetachesUpstreamContext(t *testing.T) { + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`) + c, _ := newOpenAIImagesTestContext(t, body) + + recorder := &httpUpstreamRecorder{resp: openAIImagesJSONResponse()} + svc := newOpenAIImagesTestService(recorder) + + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.False(t, parsed.Stream, "本用例覆盖非流式生图") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 客户端已断开 + + result, err := svc.ForwardImages(ctx, c, newOpenAIImagesAPIKeyAccount(), body, parsed, "") + + require.NoError(t, err, "客户端断开不应把已在出图的上游调用打断成 context canceled") + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount, "图片已产出,必须带回结果供计费") + + require.NotNil(t, recorder.lastReq) + require.NoError(t, recorder.lastReq.Context().Err(), + "交给上游的请求 context 必须已脱钩,不随客户端断开取消") +} + +// 流式路径本来就脱钩,这条守卫防止对齐时把它改坏。 +func TestForwardOpenAIImagesAPIKey_StreamKeepsDetachedUpstreamContext(t *testing.T) { + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`) + c, _ := newOpenAIImagesTestContext(t, body) + + recorder := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_ctx_stream"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000000}}\n\n" + + "data: {\"type\":\"response.image_generation_call.completed\",\"result\":\"aGVsbG8=\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}}\n\n", + )), + }} + svc := newOpenAIImagesTestService(recorder) + + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.True(t, parsed.Stream) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _ = svc.ForwardImages(ctx, c, newOpenAIImagesAPIKeyAccount(), body, parsed, "") + + require.NotNil(t, recorder.lastReq) + require.NoError(t, recorder.lastReq.Context().Err(), + "流式路径原本就脱钩,不能被改回随客户端取消") +} + +// 两个 detach 辅助函数的语义差异是本次修复的根据,锁死它们防止被悄悄改动。 +func TestDetachUpstreamContextSemantics(t *testing.T) { + t.Run("detachUpstreamContext_always_detaches", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + detached, release := detachUpstreamContext(ctx) + defer release() + require.NoError(t, detached.Err()) + }) + + t.Run("detachStreamUpstreamContext_keeps_cancel_when_not_streaming", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + same, release := detachStreamUpstreamContext(ctx, false) + defer release() + require.ErrorIs(t, same.Err(), context.Canceled, + "非流式时该函数原样返回请求 context —— 生图路径不能用它") + }) + + t.Run("detachStreamUpstreamContext_detaches_when_streaming", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + detached, release := detachStreamUpstreamContext(ctx, true) + defer release() + require.NoError(t, detached.Err()) + }) +} diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 6cce50573c..0d9f105549 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -144,6 +144,9 @@ func (s *OpenAIGatewayService) CreateLiveCall( } excluded := make(map[int64]struct{}) + // Live 按通话时长计费,不属于 token 利润门的语义范围:显式豁免,避免 + // 防御性装门按文本 D 过滤 Live 账号池且门与计费时刻不同源。 + ctx = WithOpenAIProfitControlSuppressed(ctx) var lastErr error for attempt := 0; attempt <= 3; attempt++ { selection, _, selectErr := s.SelectAccountWithSchedulerForCapability( diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go index f72b84a7f3..aedfb1b3f7 100644 --- a/backend/internal/service/openai_messages_dispatch.go +++ b/backend/internal/service/openai_messages_dispatch.go @@ -69,10 +69,14 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { } if g.Platform == PlatformGrok { - if claudeMessagesDispatchFamily(requestedModel) != "" { - return xai.DefaultModelMapping()["grok"] + if claudeMessagesDispatchFamily(requestedModel) == "" { + return "" } - return "" + opts := xai.RuntimeModelMappingOptions() + if !opts.EnableCrossClientMap { + return "" + } + return xai.ModelMappingWithOptions(opts)["claude-*"] } cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig) diff --git a/backend/internal/service/openai_messages_dispatch_test.go b/backend/internal/service/openai_messages_dispatch_test.go index db7804a4f3..fd6dea86fa 100644 --- a/backend/internal/service/openai_messages_dispatch_test.go +++ b/backend/internal/service/openai_messages_dispatch_test.go @@ -1,8 +1,11 @@ package service -import "testing" +import ( + "testing" -import "github.com/stretchr/testify/require" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) func TestNormalizeOpenAIMessagesDispatchModelConfig(t *testing.T) { t.Parallel() @@ -26,14 +29,21 @@ func TestNormalizeOpenAIMessagesDispatchModelConfig(t *testing.T) { }, cfg.ExactModelMappings) } -func TestGroupResolveMessagesDispatchModel_GrokMapsClaudeFamilyToGrok(t *testing.T) { - t.Parallel() - +func TestGroupResolveMessagesDispatchModel_GrokRequiresCrossClientMapping(t *testing.T) { + original := xai.RuntimeModelMappingOptions() + t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) }) group := &Group{Platform: PlatformGrok} - require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-sonnet-4-5")) - require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-opus-4-6")) - require.Equal(t, "grok-4.5", group.ResolveMessagesDispatchModel("claude-haiku-4-5")) + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{}) + require.Empty(t, group.ResolveMessagesDispatchModel("claude-sonnet-4-5")) + + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{ + DefaultText: "grok-build-0.1", + EnableCrossClientMap: true, + }) + require.Equal(t, "grok-build-0.1", group.ResolveMessagesDispatchModel("claude-sonnet-4-5")) + require.Equal(t, "grok-build-0.1", group.ResolveMessagesDispatchModel("claude-opus-4-6")) + require.Equal(t, "grok-build-0.1", group.ResolveMessagesDispatchModel("claude-haiku-4-5")) require.Empty(t, group.ResolveMessagesDispatchModel("grok")) require.Empty(t, group.ResolveMessagesDispatchModel("gpt-5.3-codex")) } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 6b7f1dd33d..2b3b00176f 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "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/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" @@ -414,7 +415,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali // 2) only auth is replaced; inbound auth/cookie are not forwarded require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "codex_cli_rs/0.1.0", upstream.lastReq.Header.Get("User-Agent")) + // 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游。 + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Cookie")) require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key")) require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key")) @@ -433,7 +435,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali require.NotContains(t, body, "\"name\":\"edit\"") } -func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) { +// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、 +// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉 +// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。 +func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(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") + + originalBody := []byte(`{ + "model":"gpt-5.5", + "stream":true, + "instructions":"local-test-instructions", + "tools":[ + {"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}}, + {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]} + ], + "tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"}, + "input":[ + {"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}, + {"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]} + ] + }`) + + upstreamSSE := strings.Join([]string{ + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`, + "", + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, + "", + "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_preserve"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + + result, err := svc.Forward(context.Background(), c, account, originalBody) + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2) + require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists()) + require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String()) + require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent") + + // 未摊平即无需回程还原,上游事件原样下发。 + downstream := rec.Body.String() + require.Contains(t, downstream, `"name":"spawn_agent"`) + require.Contains(t, downstream, `"namespace":"collaboration"`) +} + +// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见 +// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。 +func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -475,7 +547,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse 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"}, - Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_flatten_namespaces": true, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } result, err := svc.Forward(context.Background(), c, account, originalBody) @@ -501,7 +577,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse require.Contains(t, downstream, `"namespace":"collaboration"`) } -func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) { +// 兼容开关打开时的旧行为;默认保留路径见 +// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。 +func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -529,6 +607,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t account := &Account{ ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } @@ -596,7 +675,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t * require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`) } -func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) { +// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。 +func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -614,7 +694,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque 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"}, - Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_flatten_namespaces": true, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } result, err := svc.Forward(context.Background(), c, account, body) @@ -727,57 +811,53 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamRequestIgnoresClientCance require.NoError(t, upstream.lastReq.Context().Err()) } -func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) { +func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefault(t *testing.T) { gin.SetMode(gin.TestMode) - logSink, restore := captureStructuredLog(t) - defer restore() - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil)) - c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown") - c.Request.Header.Set("Content-Type", "application/json") - c.Request.Header.Set("OpenAI-Beta", "responses=experimental") + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + path := "/v1/responses" + responseBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", "data: [DONE]", "", + }, "\n") + responseContentType := "text/event-stream" + if !stream { + path = "/v1/responses/compact" + responseBody = `{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}` + responseContentType = "application/json" + } + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") - // Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。 - originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`) + originalBody := []byte(fmt.Sprintf(`{"model":"gpt-5.1-codex-max","stream":%t,"store":true,"input":[{"type":"text","text":"hi"}]}`, stream)) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{responseContentType}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + 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"}, + Extra: map[string]any{"openai_passthrough": true, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } - upstream := &httpUpstreamRecorder{ - resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}}, - Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), - }, + result, err := svc.Forward(context.Background(), c, account, originalBody) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + if stream { + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + } else { + require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists()) + } + require.Equal(t, strings.TrimSpace(defaultCodexSynthInstructions("gpt-5.1-codex-max")), strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String())) + }) } - - svc := &OpenAIGatewayService{ - cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, - httpUpstream: upstream, - } - - 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"}, - Extra: map[string]any{"openai_passthrough": true}, - Status: StatusActive, - Schedulable: true, - RateMultiplier: f64p(1), - } - - result, err := svc.Forward(context.Background(), c, account, originalBody) - require.Error(t, err) - require.Nil(t, result) - require.Equal(t, http.StatusForbidden, rec.Code) - require.Contains(t, rec.Body.String(), "requires a non-empty instructions field") - require.Nil(t, upstream.lastReq) - - require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")) - require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")) - require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing")) } func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) { @@ -910,9 +990,10 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - // 浏览器型复合 UA 被替换为默认 Codex UA(codex-tui 形态),originator 随最终 UA 配套(issue #3901)。 + // 浏览器型复合 UA 被替换为默认 Codex TUI UA, + // originator 随最终 UA 配套(issue #3901)。 require.Equal(t, DefaultOpenAICodexUserAgent, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator")) + require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator")) } @@ -1768,13 +1849,13 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) } -// 回归(issue #3901):codex-tui 等官方 UA 在透传模式下必须逐字保留,且 originator -// 由最终 UA 推导配套——历史实现会把 codex-tui UA 强改为 codex_cli_rs,而 originator -// 保留客户端原值,造成 originator/UA 首段错配被上游 404。 -func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityPreservedAndPaired(t *testing.T) { +// 透传模式的 OAuth 与非透传一致:官方客户端身份同样被强制统一为网关规范身份, +// originator 与 UA 首段天然配套,不会出现历史上 originator/UA 错配被上游 404 的形态 +// (issue #3901)。 +func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testing.T) { gin.SetMode(gin.TestMode) - const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)" + const tuiUA = "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)" rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -1813,8 +1894,53 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityPreservedAndPaire _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - require.Equal(t, tuiUA, upstream.lastReq.Header.Get("User-Agent")) - require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) +} + +// 透传模式下真实 TUI 客户端的身份同样被统一:被优先降载的身份不会带到上游。 +func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(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-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)") + c.Request.Header.Set("originator", "codex-tui") + + inputBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"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{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + } + + 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"}, + Extra: map[string]any{"openai_passthrough": true}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + + _, err := svc.Forward(context.Background(), c, account, inputBody) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) + require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) { diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index d4869e1487..5eef594e33 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -269,22 +269,35 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope orgID = atClaims.OpenAIAuth.POID } } + // accounts/check 命中的记录不属于个人账号时,必须改用个人订阅端点拿到期时间, + // 否则会把 workspace 权益的 expires_at 当成个人订阅到期日展示。 + forcePersonalSubscriptionLookup := false if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { // chatgpt_plan_type from the ID token is the canonical personal-plan value. // accounts/check is a multi-account/workspace endpoint; inactive team or // business workspaces can otherwise overwrite Pro/Free with internal // workspace billing plan names such as self_serve_business_usage_based. - if shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) { + appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) + if appliedAccountInfoPlanType { tokenInfo.PlanType = info.PlanType } + // plan_type 与 subscription_expires_at 必须描述同一份订阅。套餐取自 + // accounts/check 时,到期时间跟着取同一条记录;套餐保留了 JWT 里的个人值时, + // 只有该记录确实就是个人账号才能用它的 entitlement.expires_at——poid 指向的 + // 默认 Personal workspace 与 chatgpt_account_id 可以是两个不同的标识, + // 混用会显示成「个人 Pro + workspace 到期时间」。 if info.SubscriptionExpiresAt != "" { - tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt + if appliedAccountInfoPlanType || chatGPTAccountInfoBelongsToTokenAccount(tokenInfo, info) { + tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt + } else { + forcePersonalSubscriptionLookup = true + } } if tokenInfo.Email == "" && info.Email != "" { tokenInfo.Email = info.Email } } - if strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" { + if forcePersonalSubscriptionLookup || strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" { if expiresAt := fetchChatGPTSubscriptionExpiresAt(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, resolveChatGPTSubscriptionAccountID(tokenInfo, orgID)); expiresAt != "" { tokenInfo.SubscriptionExpiresAt = expiresAt } @@ -298,6 +311,17 @@ func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool { return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == "" } +// chatGPTAccountInfoBelongsToTokenAccount 判断 accounts/check 命中的那条记录是不是 +// token 自己的个人 ChatGPT 账号。两侧任一缺 ID 时无法区分,返回 true 保持既有行为。 +func chatGPTAccountInfoBelongsToTokenAccount(tokenInfo *OpenAITokenInfo, info *ChatGPTAccountInfo) bool { + personalID := strings.TrimSpace(tokenInfo.ChatGPTAccountID) + sourceID := strings.TrimSpace(info.AccountID) + if personalID == "" || sourceID == "" { + return true + } + return strings.EqualFold(personalID, sourceID) +} + func resolveChatGPTSubscriptionAccountID(tokenInfo *OpenAITokenInfo, orgID string) string { for _, candidate := range []string{ tokenInfo.ChatGPTAccountID, diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index 0212c3e1f8..15b803a7de 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -85,3 +85,20 @@ func TestNormalizeOpenAIPassthroughOAuthBody_ArrayInputUnchanged(t *testing.T) { require.Len(t, input.Array(), 1) require.Equal(t, "message", input.Array()[0].Get("type").String()) } + +func TestDetectOpenAIPassthroughInstructionsRejectReason(t *testing.T) { + for _, tt := range []struct { + name string + body string + want string + }{ + {name: "missing is optional", body: `{"model":"gpt-5.1-codex"}`, want: ""}, + {name: "non string remains rejected", body: `{"instructions":{"text":"invalid"}}`, want: "instructions_not_string"}, + {name: "empty remains rejected", body: `{"instructions":" "}`, want: "instructions_empty"}, + {name: "non empty remains accepted", body: `{"instructions":"client guidance"}`, want: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, detectOpenAIPassthroughInstructionsRejectReason("gpt-5.1-codex", []byte(tt.body))) + }) + } +} diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 5c19cc4927..192843346b 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -88,8 +88,12 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 type ChatGPTAccountInfo struct { - PlanType string - Email string + PlanType string + Email string + // AccountID 是本条信息所属账号的标识(优先取 account.account_id,否则取 accounts + // 的 map key)。accounts/check 是多账号/工作区端点,调用方需要据此判断拿到的 + // plan_type / expires_at 到底属于个人账号还是某个 workspace。 + AccountID string SubscriptionExpiresAt string // entitlement.expires_at (RFC3339) } @@ -149,7 +153,7 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac if acctRaw, ok := accounts[orgID]; ok { if acct, ok := acctRaw.(map[string]any); ok { if isUsableChatGPTAccountCandidate(acct, time.Now()) { - fillAccountInfo(info, acct) + fillAccountInfo(info, acct, orgID) } } } @@ -160,9 +164,10 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac type candidate struct { planType string expiresAt string + accountID string } var defaultC, paidC, anyC candidate - for _, acctRaw := range accounts { + for key, acctRaw := range accounts { acct, ok := acctRaw.(map[string]any) if !ok { continue @@ -175,26 +180,27 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac continue } ea := extractEntitlementExpiresAt(acct) + id := chatGPTAccountObjectID(acct, key) if anyC.planType == "" { - anyC = candidate{planType, ea} + anyC = candidate{planType, ea, id} } if account, ok := acct["account"].(map[string]any); ok { if isDefault, _ := account["is_default"].(bool); isDefault { - defaultC = candidate{planType, ea} + defaultC = candidate{planType, ea, id} } } if !strings.EqualFold(planType, "free") && paidC.planType == "" { - paidC = candidate{planType, ea} + paidC = candidate{planType, ea, id} } } // 优先级:default > 非 free > 任意 switch { case defaultC.planType != "": - info.PlanType, info.SubscriptionExpiresAt = defaultC.planType, defaultC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = defaultC.planType, defaultC.expiresAt, defaultC.accountID case paidC.planType != "": - info.PlanType, info.SubscriptionExpiresAt = paidC.planType, paidC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = paidC.planType, paidC.expiresAt, paidC.accountID default: - info.PlanType, info.SubscriptionExpiresAt = anyC.planType, anyC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = anyC.planType, anyC.expiresAt, anyC.accountID } } @@ -263,10 +269,23 @@ func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory Privac return activeUntil } -// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at -func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any) { +// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at。 +// fallbackID 是该对象在 accounts 里的 map key,用于 account.account_id 缺失时兜底。 +func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any, fallbackID string) { info.PlanType = extractPlanType(acct) info.SubscriptionExpiresAt = extractEntitlementExpiresAt(acct) + info.AccountID = chatGPTAccountObjectID(acct, fallbackID) +} + +// chatGPTAccountObjectID 取单个 account 对象的账号标识。 +// accounts 的 map key 有时是 "default" 这类别名,所以优先读 account.account_id。 +func chatGPTAccountObjectID(acct map[string]any, fallbackID string) string { + if account, ok := acct["account"].(map[string]any); ok { + if id, ok := account["account_id"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + } + return strings.TrimSpace(fallbackID) } // extractPlanType 从单个 account 对象中提取 plan_type diff --git a/backend/internal/service/openai_profit_control.go b/backend/internal/service/openai_profit_control.go new file mode 100644 index 0000000000..4153bc4094 --- /dev/null +++ b/backend/internal/service/openai_profit_control.go @@ -0,0 +1,457 @@ +package service + +// 分组利润控制(配套 migration 192/193 的 groups.profit_* 字段)。 +// +// 定位:利润控制是"候选准入过滤",只决定账号能否进入调度候选池;既有的排序、 +// 评分、粘性、熔断、负载均衡在合格账号之间照常工作,本文件不改变它们的行为。 +// +// 准入条件: +// +// U(尝试时刻) <= D(pricingAt) × (1 − profit_min_margin − profit_safety_buffer) +// +// - D(用户售价倍率)固定在请求开始的 pricingAt:同一请求的全部 failover 与 +// 最终扣费共用同一 D(RecordUsage 的高峰因子同样取 pricingAt),一个请求 +// 不会中途变价。D 与计费完全同源:按请求真实计费分组(ctxkey.Group,即 +// apiKey 自身分组;composite 请求为父分组)做 ResolveUserGroupRateMultiplier +// (用户-分组覆盖 ?? 分组默认)× Group.PeakMultiplierAt(pricingAt),绝不在 +// 用户有覆盖时退回分组默认;开关与 margin/buffer 则始终取被调度 +// openai/grok 分组。 +// - U(上游成本倍率)取 accounts.rate_multiplier。倍率可以由运营者手工维护, +// 也可以由上游倍率探测同步写回;利润门不再耦合探测协议、新鲜度或账号类型。 +// 0 是合法的免费上游倍率;nil、负数、NaN、Inf 属于非法数据并保守拒绝。 +// +// 装门点(gate 随 ctx 传播,请求内复用,覆盖等待/重试/failover/抢槽后终检): +// - handler 各文本入口经 WithOpenAIRequestPricingContext 在请求开始统一装门并 +// 固定 pricingAt。生图意图只用于能力路由与图片计费,不决定是否装门:混合 +// /v1/responses 请求(含声明了生图工具的请求)的 token 计费部分仍受利润门 +// 保护,请求体内的任何工具声明都不能作为关门开关。 +// - 门范围之外的路径显式携带 WithOpenAIProfitControlSuppressed 标记:独立 +// 图片/视频端点与 Grok 媒体(媒体计费另有倍率来源)、count_tokens(不计费)、 +// live(按时长计费)。所有装门点(含防御性装门)都尊重该标记。 +// - selectAccountWithScheduler 顶部:唯一文本调度入口的防御性装门(ctx 已有 +// 同分组门则复用,保证 failover 阈值稳定);requiredImageCapability != "" +// 的专用图片调度不装门。 +// - 公开 SelectAccountWithLoadAwareness / SelectAccountByPreviousResponseID: +// 防御性装门,保证不经唯一入口的调用方无法绕过。 +// - 选号结果通过 AccountSelectionResult 携带真实生效的门(composite/fallback +// 路由可能解析出与入口分组不同的门),handler 经 +// ContextWithSelectionProfitGate 重放后再做抢槽后终检与准入后粘性绑定。 +// - 长连接(Responses WS)在每个 turn 开始经 WithOpenAITurnPricingContext +// 重新冻结 pricingAt 并重装门:turn 的准入与计费同源,跨峰谷边界不再共用 +// 建连时刻的 D。 +// +// 否决点(消费 gate,任何 fallback 都无法把已排除账号重新放回): +// - defaultOpenAIAccountScheduler.isAccountRequestCompatibleReason:候选池 +// 过滤 + 调度器内抢槽后终检共用,named reason 进入 openAISelectionFilterStats。 +// - isOpenAICompatibleAccountEligibleForRequest:legacy 引擎与 DB recheck 共用。 +// - resolveAccountByPreviousResponseIDForCapability:previous_response 粘连 +// 两阶段校验;与 quota auto-pause 同语义,跳过复用但不删除绑定。 +// - handler 槽位获取后终检(OpenAIProfitControlVeto):快速抢槽与 WaitPlan +// 排队成功后复核,越线则释放槽位、加入本请求排除集重新选号,全池耗尽才 +// 返回标准 no available accounts。 +// +// 失败语义:分组配置读取失败时放行并告警(fail-open)。这是"配置系统故障时 +// 可用性优先"的显式取舍——该异常窗口内利润保证不成立,靠 WARN 与采样观测 +// 暴露,绝不把瞬时 DB 抖动放大成全站不可调度。 +// +// 可观测性:按分组和平台累计装门/threshold 否决/invalid-rate 否决/终检刷新 +// 失败计数,≥5 分钟采样输出一条 Info(profit_control_activity),无逐请求日志。 + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +const ( + // profitControlRateEpsilon 吸收 decimal(10,4) 落库与浮点乘法的边界误差: + // U 与阈值的相对差在该量级内视为相等(即 U == 阈值 判定为合格)。 + profitControlRateEpsilon = 1e-9 + + // 进入 openAISelectionFilterStats 的排除原因,全排除时出现在 + // "no available accounts" 的内部统计摘要里(与 quota_auto_pause 等同通道)。 + openAIProfitFilterReasonThreshold = "profit_threshold" + openAIProfitFilterReasonInvalidAccountRate = "profit_invalid_account_rate" + + // profitControlActivityLogInterval 是按分组采样输出累计计数的最小间隔。 + profitControlActivityLogInterval = 5 * time.Minute +) + +type openAIProfitControlGateCtxKey struct{} + +// openAIProfitControlSuppressCtxKey 标记本请求显式跳过利润门(独立图片/视频 +// 端点、Grok 媒体、count_tokens、live 等利润门范围外流量)。所有装门点看到该 +// 标记后一律不装门,防止 service 层防御性装门把边界外流量重新拉回利润过滤。 +type openAIProfitControlSuppressCtxKey struct{} + +// openAIPricingAtCtxKey 携带请求级定价时刻 pricingAt:门的 D 与 RecordUsage +// 的高峰因子共用,保证一个请求从准入到扣费不中途变价。 +type openAIPricingAtCtxKey struct{} + +// clampProfitControlThreshold 归一化利润门阈值。Validate/Normalize 已保证 +// margin+buffer < 1,这里只对存量脏数据兜底:阈值非有限或为负时按 0 处理 +// (等价于只放行免费上游)。装门点与 profit-preview 共用,避免口径漂移。 +func clampProfitControlThreshold(threshold float64) float64 { + if math.IsNaN(threshold) || math.IsInf(threshold, 0) || threshold < 0 { + return 0 + } + return threshold +} + +// profitControlOverThreshold 是"上游倍率越线"的唯一定义:相对 epsilon 吸收 +// decimal(10,4) 落库与浮点乘法的边界误差,U == 阈值 判定为合格。 +// 线上否决点与 profit-preview 共用,两者不得各自实现。 +func profitControlOverThreshold(upstream, threshold float64) bool { + return upstream-threshold > profitControlRateEpsilon*math.Max(1, math.Abs(threshold)) +} + +// openAIProfitControlGate 是一个请求的利润准入门。除 pricingAt 外全部为预计算 +// 标量:候选过滤热路径上每账号只做一次快照解码与一次浮点比较。 +type openAIProfitControlGate struct { + // groupID 是门配置来源的被调度分组;请求内按分组复用(failover 阈值稳定), + // composite 等跨分组调度切换分组时重新解析。 + groupID int64 + // platform 是利润配置所在分组的平台,用于按平台观测门是否真实生效。 + platform string + // threshold = D(pricingAt) × (1 − margin − buffer),账号倍率必须 <= 它。 + threshold float64 + // pricingAt 是本请求的统一定价时刻(D 侧)。 + pricingAt time.Time +} + +// WithOpenAIRequestPricingContext 在请求开始处装配请求级定价上下文:固定 +// pricingAt(返回给调用方,供 RecordUsage 入参共用同一时刻),并按分组安装 +// 利润门。ctx 携带 WithOpenAIProfitControlSuppressed 标记(门范围外流量)时 +// 只固定 pricingAt、不装门。handler 各文本入口应在选号循环前调用一次。 +func (s *OpenAIGatewayService) WithOpenAIRequestPricingContext(ctx context.Context, groupID *int64) (context.Context, time.Time) { + pricingAt := timezone.Now() + ctx = context.WithValue(ctx, openAIPricingAtCtxKey{}, pricingAt) + return s.withOpenAIProfitControlGate(ctx, groupID), pricingAt +} + +// WithOpenAIProfitControlSuppressed 标记本请求在利润门范围之外(独立图片/视频 +// 端点、Grok 媒体、count_tokens、live)。所有装门点(含 service 层防御性装门) +// 都尊重该标记;它只关闭利润准入过滤,不影响定价上下文与计费。 +func WithOpenAIProfitControlSuppressed(ctx context.Context) context.Context { + return context.WithValue(ctx, openAIProfitControlSuppressCtxKey{}, struct{}{}) +} + +// WithOpenAITurnPricingContext 在长连接(Responses WS)的每个 turn 开始重新 +// 冻结 pricingAt 并按当前配置重装利润门,使 turn 的准入与计费同源:峰前建连 +// 保活不再让后续 turn 继续按建连时刻的谷价定价。连接可能被调度到与入口分组 +// 不同的分组(composite 成员分组),turn 级重装以连接上已装门的调度分组为准; +// 连接从未装门时才回退入口分组。抑制标记下只刷新 pricingAt。 +func (s *OpenAIGatewayService) WithOpenAITurnPricingContext(ctx context.Context, groupID *int64) (context.Context, time.Time) { + pricingAt := timezone.Now() + ctx = context.WithValue(ctx, openAIPricingAtCtxKey{}, pricingAt) + if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { + return ctx, pricingAt + } + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing != nil { + gid := existing.groupID + groupID = &gid + } + gate := s.resolveOpenAIProfitControlGate(ctx, groupID) + if gate == nil { + // 分组已关门(或配置读取失败 fail-open):清除旧 turn 的门,后续 turn + // 按无门放行,与 HTTP 路径的开关语义一致。 + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing != nil { + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, (*openAIProfitControlGate)(nil)), pricingAt + } + return ctx, pricingAt + } + openAIProfitControlObserverInstance.recordInstall(gate.groupID, gate.platform, gate.threshold) + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, gate), pricingAt +} + +// openAIPricingAtFromContext 返回请求级定价时刻;未装配(内部调用、非文本 +// 入口)时 ok=false,调用方回退 timezone.Now() 保持既有行为。 +func openAIPricingAtFromContext(ctx context.Context) (time.Time, bool) { + pricingAt, ok := ctx.Value(openAIPricingAtCtxKey{}).(time.Time) + return pricingAt, ok && !pricingAt.IsZero() +} + +// OpenAIPricingAtFromContext 是 handler 侧读取请求级定价时刻的公开入口(未装配 +// 时为零值,RecordUsage 对零值回退记录时刻)。供跨函数传递 pricingAt 不便的 +// 记录路径直接从请求 ctx 取值。 +func OpenAIPricingAtFromContext(ctx context.Context) time.Time { + pricingAt, _ := openAIPricingAtFromContext(ctx) + return pricingAt +} + +// withOpenAIProfitControlGate 解析分组利润控制配置;启用时把预计算好的准入门 +// 装进 ctx。抑制标记、未启用/非 openai 分组/无法取到分组配置时原样返回 ctx +// (门不存在,全部否决点自动放行,既有行为零变化)。ctx 已有同分组门时直接 +// 复用:同一请求的全部 failover 重入共享同一阈值。 +func (s *OpenAIGatewayService) withOpenAIProfitControlGate(ctx context.Context, groupID *int64) context.Context { + if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { + return ctx + } + if groupID != nil { + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing != nil && existing.groupID == *groupID { + return ctx + } + } + gate := s.resolveOpenAIProfitControlGate(ctx, groupID) + if gate == nil { + // 被调度分组无门(未启用/非 openai/配置读取失败)而 ctx 带着其他分组的 + // 请求门时清除之:门配置取被调度分组,父分组阈值不得泄漏到成员分组 + //(composite/模型路由等跨分组调度)。typed-nil 覆盖值由否决点按无门放行。 + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing != nil && groupID != nil && existing.groupID != *groupID { + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, (*openAIProfitControlGate)(nil)) + } + return ctx + } + openAIProfitControlObserverInstance.recordInstall(gate.groupID, gate.platform, gate.threshold) + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, gate) +} + +func (s *OpenAIGatewayService) resolveOpenAIProfitControlGate(ctx context.Context, groupID *int64) *openAIProfitControlGate { + if s == nil || groupID == nil || *groupID <= 0 { + return nil + } + // 门配置取被调度分组。直连请求(ctx 认证分组即调度分组,生产绝大多数流量) + // 直接复用 auth cache 分组,热路径零额外查询;composite 父分组路由到成员 + // 分组等 ID 不一致场景才回源仓库读取。auth 快照的分组字段完备性由 + // GetByKeyForAuth 投影 + 集成测试保证(防投影漏列导致门静默失效)。 + var group *Group + if ctxGroup, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(ctxGroup) && ctxGroup.ID == *groupID { + group = ctxGroup + } else if s.schedulerSnapshot != nil { + // Lite 读取:门只用平台/倍率/利润/高峰字段,不需要账号计数聚合。 + loaded, err := s.schedulerSnapshot.GetGroupByIDLite(ctx, *groupID) + if err != nil { + // fail-open:配置系统故障时可用性优先,该窗口内利润保证不成立, + // 依赖 WARN 暴露;不把瞬时 DB 抖动放大成全站不可调度。 + slog.Warn("profit_control_group_load_failed", "group_id", *groupID, "error", err) + return nil + } + group = loaded + } + if group == nil || !group.ProfitControlEnabled || + (group.Platform != PlatformOpenAI && group.Platform != PlatformGrok) { + return nil + } + + pricingAt, ok := openAIPricingAtFromContext(ctx) + if !ok { + pricingAt = timezone.Now() + } + // D 与计费完全同源(RecordUsage 组合):计费永远按 apiKey 自身分组 + //(composite 请求即父分组)的"用户覆盖 ?? 分组默认 × 高峰因子"计算, + // 因此优先取认证中间件放入 ctx 的分组;ctx 中无有效分组(内部调用)时 + // 退回调度分组组合,直连 openai 分组场景两者等价。 + billingGroup := group + if ctxGroup, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(ctxGroup) { + billingGroup = ctxGroup + } + downstream := billingGroup.RateMultiplier + if userID, _ := ctx.Value(ctxkey.UserID).(int64); userID > 0 { + downstream = s.ResolveUserGroupRateMultiplier(ctx, userID, billingGroup.ID, billingGroup.RateMultiplier) + } + downstream *= billingGroup.PeakMultiplierAt(pricingAt) + + deduction := group.ProfitMinMargin + group.ProfitSafetyBuffer + threshold := clampProfitControlThreshold(downstream * (1 - deduction)) + return &openAIProfitControlGate{ + groupID: *groupID, + platform: group.Platform, + threshold: threshold, + pricingAt: pricingAt, + } +} + +// attachSelectionProfitGate 把调度上下文里生效的利润门记录到选号结果上。门在 +// 选号函数内部的局部 ctx 上安装(composite/fallback 路由还可能解析出与入口 +// 分组不同的门),不随返回值离开调度栈;结果携带后 handler 才能对"真实过滤了 +// 候选的那个门"做抢槽后终检与准入后绑定。 +func attachSelectionProfitGate(ctx context.Context, sel *AccountSelectionResult) *AccountSelectionResult { + if sel == nil { + return nil + } + if gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && gate != nil { + sel.profitGate = gate + } + return sel +} + +// ContextWithSelectionProfitGate 把选号时真实生效的利润门重放到 ctx 上。 +// handler 在拿到选号结果后必须用返回的 ctx 做抢槽后终检 +// (ProfitControlVetoLatest / GatewayProfitControlVetoLatest)与准入后粘性 +// 绑定,否则这两步会因为看不到调度栈内安装的门而退化为空操作。 +func ContextWithSelectionProfitGate(ctx context.Context, sel *AccountSelectionResult) context.Context { + if sel == nil || sel.profitGate == nil { + return ctx + } + if existing, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); ok && existing == sel.profitGate { + return ctx + } + return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, sel.profitGate) +} + +// openAIProfitControlVetoReason 报告利润门是否否决该账号。ctx 中没有门 +// (分组未启用利润控制或本请求跳门)或账号为 nil 时一律放行。 +func openAIProfitControlVetoReason(ctx context.Context, account *Account) (bool, string) { + gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + if gate == nil || account == nil { + return false, "" + } + if account.RateMultiplier == nil || + math.IsNaN(*account.RateMultiplier) || + math.IsInf(*account.RateMultiplier, 0) || + *account.RateMultiplier < 0 { + openAIProfitControlObserverInstance.recordVeto(gate.groupID, gate.platform, gate.threshold, openAIProfitFilterReasonInvalidAccountRate) + return true, openAIProfitFilterReasonInvalidAccountRate + } + upstream := *account.RateMultiplier + if profitControlOverThreshold(upstream, gate.threshold) { + openAIProfitControlObserverInstance.recordVeto(gate.groupID, gate.platform, gate.threshold, openAIProfitFilterReasonThreshold) + return true, openAIProfitFilterReasonThreshold + } + return false, "" +} + +// OpenAIProfitControlVeto 是 handler 层槽位获取后终检的公开入口:语义与调度 +// 内否决点完全一致。ctx 必须是经 WithOpenAIRequestPricingContext 装配过的 +// 请求上下文,否则视为无门放行。 +func OpenAIProfitControlVeto(ctx context.Context, account *Account) (bool, string) { + return openAIProfitControlVetoReason(ctx, account) +} + +// ProfitControlVetoLatest performs the handler-side terminal check after a +// concurrency slot is actually acquired. The latest cached account replaces +// the selection snapshot when available, so a probe/manual rate change during +// wait time cannot pass on a stale pointer. +func (s *OpenAIGatewayService) ProfitControlVetoLatest(ctx context.Context, selected *Account) (*Account, bool, string) { + if s == nil { + return selected, false, "" + } + return profitControlVetoLatest(ctx, selected, s.schedulerSnapshot) +} + +// bindOpenAIStickySessionDuringSelection preserves the official eager binding +// behavior for requests without a profit gate. Profit-controlled requests bind +// 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) { + return nil + } + return s.BindStickySession(ctx, groupID, sessionHash, accountID) +} + +// BindStickySessionAfterProfitAdmission records the terminally admitted +// account. Without a profit gate it preserves the pre-existing eager binding +// behavior at the handler bind points. With a gate it never overwrites a +// different binding that already exists, so a temporarily ineligible account +// remains sticky and becomes eligible again automatically after its rate +// recovers. +func (s *OpenAIGatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { + if sessionHash == "" || accountID <= 0 { + return nil + } + if !gatewayProfitControlGateActive(ctx) { + return s.BindStickySession(ctx, groupID, sessionHash, accountID) + } + existingAccountID, err := s.getStickySessionAccountID(ctx, groupID, sessionHash) + if err != nil && !errors.Is(err, ErrStickySessionNotFound) { + slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err) + return nil + } + if existingAccountID > 0 && existingAccountID != accountID { + return nil + } + return s.BindStickySession(ctx, groupID, sessionHash, accountID) +} + +// ---- 可观测性:按分组累计计数 + 采样日志(无逐请求输出) ---- +// +// 计数按"每次准入评估"累计,而非每请求:粘性层校验与候选池过滤可能对同一账号 +// 各评估一次,failover 重入也会再次计数。计数用于确认门在真实流量上生效及否决 +// 构成,不能当作精确的请求数或账号数。 + +type openAIProfitControlGroupStats struct { + installs atomic.Int64 + vetoThreshold atomic.Int64 + vetoInvalidRate atomic.Int64 + refreshFailures atomic.Int64 + lastLogUnixMilli atomic.Int64 +} + +type openAIProfitControlObserver struct { + groups sync.Map // "platform:groupID" -> *openAIProfitControlGroupStats +} + +var openAIProfitControlObserverInstance = &openAIProfitControlObserver{} + +func profitControlObserverKey(groupID int64, platform string) string { + return platform + ":" + fmt.Sprintf("%d", groupID) +} + +func (o *openAIProfitControlObserver) stats(groupID int64, platform string) *openAIProfitControlGroupStats { + key := profitControlObserverKey(groupID, platform) + if v, ok := o.groups.Load(key); ok { + if s, ok := v.(*openAIProfitControlGroupStats); ok { + return s + } + } + v, _ := o.groups.LoadOrStore(key, &openAIProfitControlGroupStats{}) + if s, ok := v.(*openAIProfitControlGroupStats); ok { + return s + } + // 不可达:map 中只存 *openAIProfitControlGroupStats;兜底返回独立实例避免 panic。 + return &openAIProfitControlGroupStats{} +} + +func (o *openAIProfitControlObserver) recordInstall(groupID int64, platform string, threshold float64) { + s := o.stats(groupID, platform) + s.installs.Add(1) + o.maybeLog(groupID, platform, threshold, s) +} + +func (o *openAIProfitControlObserver) recordVeto(groupID int64, platform string, threshold float64, reason string) { + s := o.stats(groupID, platform) + switch reason { + case openAIProfitFilterReasonThreshold: + s.vetoThreshold.Add(1) + case openAIProfitFilterReasonInvalidAccountRate: + s.vetoInvalidRate.Add(1) + } + o.maybeLog(groupID, platform, threshold, s) +} + +func (o *openAIProfitControlObserver) recordRefreshFailure(groupID int64, platform string, threshold float64) { + s := o.stats(groupID, platform) + s.refreshFailures.Add(1) + o.maybeLog(groupID, platform, threshold, s) +} + +// maybeLog 以 CAS 保证同分组 ≥ profitControlActivityLogInterval 才输出一条 +// 累计计数 Info;计数为进程内累计值,用于确认门在真实流量上生效及否决构成。 +func (o *openAIProfitControlObserver) maybeLog(groupID int64, platform string, threshold float64, s *openAIProfitControlGroupStats) { + now := time.Now().UnixMilli() + last := s.lastLogUnixMilli.Load() + if last != 0 && now-last < profitControlActivityLogInterval.Milliseconds() { + return + } + if !s.lastLogUnixMilli.CompareAndSwap(last, now) { + return + } + slog.Info("profit_control_activity", + "group_id", groupID, + "platform", platform, + "threshold", threshold, + "installs_total", s.installs.Load(), + "veto_threshold_total", s.vetoThreshold.Load(), + "veto_invalid_account_rate_total", s.vetoInvalidRate.Load(), + "refresh_failure_total", s.refreshFailures.Load(), + ) +} diff --git a/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go new file mode 100644 index 0000000000..19e6b027e2 --- /dev/null +++ b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func legacyProfitDiagnosticService(accounts []Account) *OpenAIGatewayService { + return &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: accounts}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + } +} + +func legacyProfitDiagnosticAccount(id int64) *Account { + now := time.Now() + a := upstreamCostTestAccount(id, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute) + a.Status = StatusActive + a.Schedulable = true + a.Concurrency = 1 + return a +} + +func TestSelectAccountWithScheduler_LegacyProfitDiagnostics(t *testing.T) { + groupID := int64(5313) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + t.Run("threshold reports deterministic pool count", func(t *testing.T) { + account := legacyProfitDiagnosticAccount(53131) + profitControlTestAccountWithRate(account, 0.9) + svc := legacyProfitDiagnosticService([]Account{*account}) + + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "pool=1, filtered: profit_threshold=1") + }) + + t.Run("missing account rate reports invalid rate", func(t *testing.T) { + account := upstreamCostTestOAuthAccount(53132) + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 1 + svc := legacyProfitDiagnosticService([]Account{*account}) + + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "profit_invalid_account_rate=1") + }) + + t.Run("model support gate does not report profit reasons", func(t *testing.T) { + account := legacyProfitDiagnosticAccount(53133) + profitControlTestAccountWithRate(account, 0.9) + account.Credentials = map[string]any{"model_mapping": map[string]any{"other-model": "other-model"}} + svc := legacyProfitDiagnosticService([]Account{*account}) + + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "ineligible=1") + require.NotContains(t, err.Error(), openAIProfitFilterReasonThreshold) + require.NotContains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate) + }) + + t.Run("compact preserves compact sentinel", func(t *testing.T) { + account := legacyProfitDiagnosticAccount(53134) + account.Extra = map[string]any{"openai_compact_supported": false} + profitControlTestAccountWithRate(account, 0.4) + svc := legacyProfitDiagnosticService([]Account{*account}) + + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, true) + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableCompactAccounts) + require.False(t, strings.Contains(err.Error(), "pool="), err) + require.False(t, errors.Is(err, ErrNoAvailableAccounts)) + }) +} diff --git a/backend/internal/service/openai_profit_control_paths_test.go b/backend/internal/service/openai_profit_control_paths_test.go new file mode 100644 index 0000000000..239a7d26f2 --- /dev/null +++ b/backend/internal/service/openai_profit_control_paths_test.go @@ -0,0 +1,377 @@ +package service + +// 利润控制请求路径矩阵测试:证明所有文本调度路径都经过利润准入过滤, +// 且任何 fallback 都不能把已排除账号重新放回候选。 +// 覆盖:高级调度器候选池(openai_profit_control_test.go)、legacy 引擎、 +// previous_response WSv2 粘连(跳过复用但保留绑定 + 倍率恢复重粘连)、 +// failover 排除不回收、抢槽后终检、倍率恢复重新准入、 +// 用户覆盖倍率 D、composite 计费分组与调度分组分离。 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +func profitControlWSAccount(id int64, rate float64, now time.Time) Account { + account := upstreamCostTestAccount(id, UpstreamBillingProbeStatusOK, rate, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(account, rate) + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 2 + account.Extra["openai_apikey_responses_websockets_v2_enabled"] = true + return *account +} + +// previous_response_id 粘连:利润不合格 → 跳过复用但不删绑定;倍率恢复 → 重新粘连。 +func TestProfitControl_PreviousResponseStickyVetoKeepsBinding(t *testing.T) { + ctx := profitControlTestCtx(profitControlTestGroup(23, 0.5, 0)) + groupID := int64(23) + now := time.Now() + expensive := profitControlWSAccount(31, 0.8, now) + + cache := &stubGatewayCache{} + store := NewOpenAIWSStateStore(cache) + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{expensive}}, + cache: cache, + cfg: newOpenAIWSV2TestConfig(), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + openaiWSStateStore: store, + } + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_profit", expensive.ID, time.Hour)) + + selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_profit", "gpt-5.1", nil, false) + require.NoError(t, err) + require.Nil(t, selection, "上游倍率 0.8 超过阈值 0.5 的账号不应继续命中 previous_response_id 粘连") + + // 利润不合格与 quota auto-pause 同为暂时状态:绑定必须保留。 + boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_profit") + require.NoError(t, getErr) + require.Equal(t, expensive.ID, boundAccountID) + + // 上游倍率回落(探测刷新)后同一绑定重新可用。 + recovered := profitControlWSAccount(31, 0.3, time.Now()) + svc.accountRepo = stubOpenAIAccountRepo{accounts: []Account{recovered}} + selection, err = svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_profit", "gpt-5.1", nil, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, recovered.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +// legacy 引擎(高级调度器关闭):候选过滤、全排除错误语义与既有语义一致。 +func TestProfitControl_LegacyEngineFiltersCandidates(t *testing.T) { + now := time.Now() + cheap := upstreamCostTestAccount(41, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) + expensive := upstreamCostTestAccount(42, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(cheap, 0.3) + profitControlTestAccountWithRate(expensive, 0.8) + for _, account := range []*Account{cheap, expensive} { + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 2 + } + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + } + groupID := int64(7) + + t.Run("legacy path only admits profitable accounts", func(t *testing.T) { + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, cheap.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + } + }) + + t.Run("legacy path all excluded returns standard error", func(t *testing.T) { + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + }) + + t.Run("legacy path keeps official behavior when gate disabled", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.7, 0.1) + group.ProfitControlEnabled = false + selection, _, err := svc.SelectAccountWithScheduler(profitControlTestCtx(group), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) +} + +// failover:可盈利账号因失败被排除后,剩余不合格账号不得被"放回"候选。 +func TestProfitControl_FailoverDoesNotReadmitExcluded(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + + now := time.Now() + cheap := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) + expensive := upstreamCostTestAccount(52, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(cheap, 0.3) + profitControlTestAccountWithRate(expensive, 0.8) + for _, account := range []*Account{cheap, expensive} { + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 2 + } + cache := &upstreamCostTrackingConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{ + cheap.ID: {AccountID: cheap.ID}, + expensive.ID: {AccountID: expensive.ID}, + }} + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(cache), + } + groupID := int64(7) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + // 模拟 failover:上一轮失败的 cheap 已进入 excludedIDs,仅剩 expensive 不合格。 + excluded := map[int64]struct{}{cheap.ID: {}} + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection, "failover 后不得回收利润不合格账号") + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=1") +} + +// 抢槽后终检:候选构建后才变得不合格的账号(状态竞态)在取得槽位前被拦截。 +func TestProfitControl_PostSlotRecheckVetoes(t *testing.T) { + now := time.Now() + expensive := upstreamCostTestAccount(61, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(expensive, 0.8) + expensive.Status = StatusActive + expensive.Schedulable = true + expensive.Concurrency = 2 + + cache := &upstreamCostTrackingConcurrencyCache{} + scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{ + concurrencyService: NewConcurrencyService(cache), + }} + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + threshold: 0.5, + pricingAt: now, + }) + selectionOrder := []openAIAccountCandidateScore{{ + account: expensive, + loadInfo: &AccountLoadInfo{AccountID: expensive.ID}, + }} + + selection, _, err := scheduler.tryAcquireOpenAISelectionOrder(ctx, OpenAIAccountScheduleRequest{Platform: PlatformOpenAI}, selectionOrder) + require.NoError(t, err) + require.Nil(t, selection, "抢槽终检必须拦截候选构建后才不合格的账号") + require.Equal(t, cache.totalAcquires(), cache.releaseCount(expensive.ID), "被拦截账号不得泄漏并发槽位") +} + +// 倍率恢复:探测刷新回落到阈值内后,此前被排除的账号重新参与调度。 +func TestProfitControl_RateRecoveryReadmitsAccount(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + + now := time.Now() + expensive := upstreamCostTestAccount(71, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(expensive, 0.8) + expensive.Status = StatusActive + expensive.Schedulable = true + expensive.Concurrency = 2 + cache := &upstreamCostTrackingConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{ + expensive.ID: {AccountID: expensive.ID}, + }} + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*expensive}}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(cache), + } + groupID := int64(7) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.Error(t, err) + + // 同步/手工写回:账号倍率回落到 0.3(阈值 0.5 内)后自动恢复参与。 + recovered := upstreamCostTestAccount(71, UpstreamBillingProbeStatusOK, 0.3, time.Now().Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(recovered, 0.3) + recovered.Status = StatusActive + recovered.Schedulable = true + recovered.Concurrency = 2 + svc.accountRepo = schedulerTestOpenAIAccountRepo{accounts: []Account{*recovered}} + + selection, _, err = svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, recovered.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +type profitControlUserRateRepo struct { + UserGroupRateRepository + rate *float64 +} + +func (r profitControlUserRateRepo) GetByUserAndGroup(context.Context, int64, int64) (*float64, error) { + return r.rate, nil +} + +// D 必须取请求用户的真实倍率:有用户覆盖时用覆盖值,绝不退回分组默认。 +func TestProfitControl_GateUsesUserOverrideRate(t *testing.T) { + override := 0.5 + svc := &OpenAIGatewayService{ + userGroupRateResolver: newUserGroupRateResolver( + profitControlUserRateRepo{rate: &override}, nil, time.Minute, nil, "test.profit", + ), + } + groupID := int64(7) + group := profitControlTestGroup(groupID, 0, 0) + group.RateMultiplier = 2.0 + + ctx := context.WithValue(profitControlTestCtx(group), ctxkey.UserID, int64(42)) + gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "阈值必须基于用户覆盖倍率 0.5,而不是分组默认 2.0") + + // 无用户身份(内部调用)时按分组默认倍率计算。 + gate = svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + require.NotNil(t, gate) + require.InDelta(t, 2.0, gate.threshold, 1e-12) +} + +type profitControlGroupRepo struct { + GroupRepository + group *Group +} + +func (r profitControlGroupRepo) GetByIDLite(context.Context, int64) (*Group, error) { + return r.group, nil +} + +// GetByID 故意 panic:利润门只需要分组配置,不需要 GetByID 附带的账号计数 +// 聚合查询。装门走 GetByID 会在 composite/模型路由/fallback 的每次装门(WS +// 每 turn 一次)上多打一条聚合,且发生在「是否启用利润控制」判定之前。 +func (r profitControlGroupRepo) GetByID(context.Context, int64) (*Group, error) { + panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") +} + +// composite 路由:门配置取被调度成员分组,D 取请求真实计费分组(ctx 认证分组)。 +func TestProfitControl_CompositeUsesBillingGroupRate(t *testing.T) { + memberGroupID := int64(7) + memberGroup := profitControlTestGroup(memberGroupID, 0.5, 0) + memberGroup.RateMultiplier = 99 // 若 D 误取成员分组倍率,阈值会是 49.5 + + billingGroup := &Group{ + ID: 1001, + Platform: PlatformComposite, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 1.0, + } + svc := &OpenAIGatewayService{ + schedulerSnapshot: &SchedulerSnapshotService{groupRepo: profitControlGroupRepo{group: memberGroup}}, + } + + ctx := profitControlTestCtx(billingGroup) + gate := svc.resolveOpenAIProfitControlGate(ctx, &memberGroupID) + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "D 必须来自计费分组(composite 父分组)倍率 1.0") +} + +// legacy 引擎与 DB recheck 共用的资格判定直接覆盖利润门。 +func TestProfitControl_EligibilityFunctionVetoes(t *testing.T) { + now := time.Now() + gateCtx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + threshold: 0.5, + pricingAt: now, + }) + cheap := upstreamCostTestAccount(81, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) + expensive := upstreamCostTestAccount(82, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(cheap, 0.3) + profitControlTestAccountWithRate(expensive, 0.8) + for _, account := range []*Account{cheap, expensive} { + account.Status = StatusActive + account.Schedulable = true + } + + require.True(t, isOpenAICompatibleAccountEligibleForRequest(gateCtx, cheap, PlatformOpenAI, "", false, "")) + require.False(t, isOpenAICompatibleAccountEligibleForRequest(gateCtx, expensive, PlatformOpenAI, "", false, "")) + // 无门时保持既有行为。 + require.True(t, isOpenAICompatibleAccountEligibleForRequest(context.Background(), expensive, PlatformOpenAI, "", false, "")) +} + +// legacy 引擎粘性写回(评审 M-Legacy 回归):门下选号阶段不得直写粘性—— +// 终检否决的账号不能成为新绑定;无门保持官方 eager 绑定与原 TTL 语义。 +func TestProfitControl_LegacyEngineDefersStickyBindingUnderGate(t *testing.T) { + now := time.Now() + cheap := upstreamCostTestAccount(45, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) + expensive := upstreamCostTestAccount(46, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(cheap, 0.3) + profitControlTestAccountWithRate(expensive, 0.8) + for _, account := range []*Account{cheap, expensive} { + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 2 + } + groupID := int64(9) + const sessionHash = "legacy-sticky" + newSvc := func(bindings map[string]int64) (*OpenAIGatewayService, *schedulerTestGatewayCache) { + cache := &schedulerTestGatewayCache{sessionBindings: bindings} + return &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{*cheap, *expensive}}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + cache: cache, + }, cache + } + + t.Run("gated selection defers binding to terminal admission", func(t *testing.T) { + svc, cache := newSvc(map[string]int64{}) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", sessionHash, "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, cheap.ID, selection.Account.ID) + require.Empty(t, cache.sessionBindings, "门下 legacy 选号阶段不得直写粘性绑定") + require.True(t, selection.ProfitGateActive(), "legacy 选号结果同样携带门") + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + + t.Run("ungated selection keeps official eager binding", func(t *testing.T) { + svc, cache := newSvc(map[string]int64{}) + group := profitControlTestGroup(groupID, 0.5, 0) + group.ProfitControlEnabled = false + selection, _, err := svc.SelectAccountWithScheduler(profitControlTestCtx(group), &groupID, "", sessionHash, "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotEmpty(t, cache.sessionBindings, "无门时 legacy 选号保持官方 eager 绑定") + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) +} diff --git a/backend/internal/service/openai_profit_control_pricing_test.go b/backend/internal/service/openai_profit_control_pricing_test.go new file mode 100644 index 0000000000..03077d2a8b --- /dev/null +++ b/backend/internal/service/openai_profit_control_pricing_test.go @@ -0,0 +1,273 @@ +package service + +// 请求级定价与利润门回归:请求级 pricingAt 定价上下文、门复用(failover 阈值稳定)、 +// 生图意图跳门、U 使用账号倍率且与探测新鲜度解耦、 +// 用量记录定价时刻取值。 + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +// WithOpenAIRequestPricingContext:装门 + 固定 pricingAt;显式抑制标记 +// (媒体/count_tokens/live 等门范围外路径)跳门且防御性装门无法把门加回来。 +func TestProfitControl_RequestPricingContext(t *testing.T) { + svc := &OpenAIGatewayService{} + groupID := int64(61) + now := time.Now() + expensive := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(expensive, 0.8) + + t.Run("installs gate and pricing instant", func(t *testing.T) { + base := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID) + require.False(t, pricingAt.IsZero()) + require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) + vetoed, reason := OpenAIProfitControlVeto(ctx, expensive) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + }) + + t.Run("suppress marker skips gate everywhere", func(t *testing.T) { + base := WithOpenAIProfitControlSuppressed(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0))) + ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID) + require.False(t, pricingAt.IsZero(), "跳门时 pricingAt 仍需固定供计费共用") + vetoed, _ := OpenAIProfitControlVeto(ctx, expensive) + require.False(t, vetoed) + // service 层防御性装门也必须被抑制标记挡住。 + reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID) + vetoed, _ = OpenAIProfitControlVeto(reCtx, expensive) + require.False(t, vetoed) + }) +} + +// failover 重入复用同一门:请求中途分组配置变化不得改变本请求阈值。 +func TestProfitControl_GateReuseKeepsThresholdAcrossFailover(t *testing.T) { + svc := &OpenAIGatewayService{} + groupID := int64(62) + group := profitControlTestGroup(groupID, 0.5, 0) + ctx := svc.withOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.InDelta(t, 0.5, gate.threshold, 1e-12) + + // 模拟请求进行中管理员改配置(ctx 分组为同一指针,与 auth 快照语义一致)。 + group.ProfitMinMargin = 0.9 + reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID) + reGate, ok := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.Same(t, gate, reGate, "failover 重入必须复用同一门,阈值不得中途变化") + + // 换分组(composite/模型路由成员调度)重新解析;成员分组无门时必须清除 + // 父分组门,阈值不得跨组泄漏。 + otherID := int64(63) + otherCtx := svc.withOpenAIProfitControlGate(reCtx, &otherID) + otherGate, _ := otherCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.Nil(t, otherGate, "成员分组未启用利润控制时父分组门必须清除") + now := time.Now() + expensive := upstreamCostTestAccount(8, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute) + vetoed, _ := openAIProfitControlVetoReason(otherCtx, expensive) + require.False(t, vetoed) +} + +// D 固定在 pricingAt:高峰因子按请求开始时刻计算,与"当前时刻"无关。 +func TestProfitControl_PricingAtFixesDownstreamPeakFactor(t *testing.T) { + svc := &OpenAIGatewayService{} + groupID := int64(64) + group := profitControlTestGroup(groupID, 0, 0) + group.SubscriptionType = SubscriptionTypeSubscription + group.PeakRateEnabled = true + group.PeakRateMultiplier = 3.0 + + pricingAt := time.Date(2026, time.January, 15, 8, 30, 0, 0, timezone.Location()) + outsideWindow := time.Date(2026, time.January, 15, 10, 30, 0, 0, timezone.Location()) + group.PeakStart = "08:00" + group.PeakEnd = "09:00" + require.Equal(t, 1.0, group.PeakMultiplierAt(outsideWindow), "构造前提:对照时刻不在窗口内") + require.Equal(t, 3.0, group.PeakMultiplierAt(pricingAt), "构造前提:pricingAt 在窗口内") + + ctx := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, pricingAt) + gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) + require.NotNil(t, gate) + require.InDelta(t, 3.0, gate.threshold, 1e-9, "阈值必须用 pricingAt 时刻的高峰因子(1.0×3.0×(1-0))") + require.Equal(t, pricingAt, gate.pricingAt) +} + +// U 只取账号倍率:探测快照内容和新鲜度不再直接参与利润判断。 +func TestProfitControl_UsesAccountRateInsteadOfProbeSnapshot(t *testing.T) { + gate := &openAIProfitControlGate{threshold: 0.5, pricingAt: time.Now().Add(-12 * time.Hour)} + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, gate) + account := upstreamCostTestAccount(9, UpstreamBillingProbeStatusOK, 0.1, time.Now().Add(-3*time.Hour), 30*time.Minute) + profitControlTestAccountWithRate(account, 0.8) + vetoed, reason := openAIProfitControlVetoReason(ctx, account) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) +} + +// 显式生图意图(requiredCapability=Responses)在唯一调度入口跳门(图片边界不装门)。 +func TestProfitControl_ResponsesImageIntentSkipsGateAtScheduler(t *testing.T) { + now := time.Now() + expensive := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + expensive.Status = StatusActive + expensive.Schedulable = true + expensive.Concurrency = 2 + svc := &OpenAIGatewayService{ + accountRepo: stubOpenAIAccountRepo{accounts: []Account{*expensive}}, + cfg: &config.Config{}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), + } + groupID := int64(77) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + _, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true) + 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() + } +} + +// 账号倍率缺失一律视为非法保守拒绝;手工或同步维护了倍率的任意账号类型都按 +// 同一阈值判断(OAuth 与 API Key 无差别)。 +func TestProfitControl_AccountRateSemantics(t *testing.T) { + now := time.Now() + missing := upstreamCostTestOAuthAccount(2) + manualOAuth := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(3), 0.3) + expensive := profitControlTestAccountWithRate(upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.1, now.Add(-3*time.Hour), 30*time.Minute), 0.8) + + group := profitControlTestGroup(77, 0.5, 0) + group.RateMultiplier = 1 + base := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, now) + gate := (&OpenAIGatewayService{}).resolveOpenAIProfitControlGate(base, &group.ID) + require.NotNil(t, gate) + gateCtx := context.WithValue(base, openAIProfitControlGateCtxKey{}, gate) + + vetoed, reason := openAIProfitControlVetoReason(gateCtx, missing) + require.True(t, vetoed, "缺失账号倍率必须保守拒绝") + require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason) + + vetoed, _ = openAIProfitControlVetoReason(gateCtx, manualOAuth) + require.False(t, vetoed, "手工维护的 OAuth 倍率应正常准入") + + vetoed, reason = openAIProfitControlVetoReason(gateCtx, expensive) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) +} + +// 用量记录定价时刻:优先请求级 PricingAt,未装配回退记录时刻。 +func TestOpenAIUsagePricingAt(t *testing.T) { + fixed := time.Now().Add(-2 * time.Hour) + require.Equal(t, fixed, openAIUsagePricingAt(&OpenAIRecordUsageInput{PricingAt: fixed})) + fallback := openAIUsagePricingAt(&OpenAIRecordUsageInput{}) + require.WithinDuration(t, timezone.Now(), fallback, 5*time.Second) + require.WithinDuration(t, timezone.Now(), openAIUsagePricingAt(nil), 5*time.Second) +} + +func TestOpenAIProfitControlStickyBindingOccursOnlyAfterTerminalAdmission(t *testing.T) { + groupID := int64(81) + expensiveID := int64(901) + cheapID := int64(902) + const sessionHash = "profit-sticky" + const cacheKey = "openai:" + sessionHash + cache := &schedulerTestGatewayCache{ + sessionBindings: map[string]int64{cacheKey: expensiveID}, + } + svc := &OpenAIGatewayService{cache: cache} + ctx := context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + groupID: groupID, + platform: PlatformOpenAI, + threshold: 0.5, + }) + + require.NoError(t, svc.bindOpenAIStickySessionDuringSelection(ctx, &groupID, sessionHash, cheapID)) + require.Equal(t, expensiveID, cache.sessionBindings[cacheKey], "选号阶段不得覆盖原粘性绑定") + + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(ctx, &groupID, sessionHash, cheapID)) + require.Equal(t, expensiveID, cache.sessionBindings[cacheKey], "终检通过的 fallback 账号不得覆盖原粘性绑定") + + cache.sessionBindings[cacheKey] = 0 + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(ctx, &groupID, sessionHash, cheapID)) + require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无既有绑定时应在终检通过后建立粘性") +} + +// WithOpenAITurnPricingContext:长连接 turn 边界重新冻结 pricingAt 并按当前 +// 配置重装门(区别于请求级同门复用);已装门时以门所属调度分组为准。 +func TestProfitControl_TurnPricingContext(t *testing.T) { + svc := &OpenAIGatewayService{} + groupID := int64(63) + expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.8, time.Now().Add(-time.Minute), 30*time.Minute) + profitControlTestAccountWithRate(expensive, 0.8) + + t.Run("refreshes instant and re-resolves gate config", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.5, 0) + base := profitControlTestCtx(group) + connCtx, connAt := svc.WithOpenAIRequestPricingContext(base, &groupID) + vetoed, _ := OpenAIProfitControlVeto(connCtx, expensive) + require.True(t, vetoed) + + // 连接中途运营者放宽 margin:turn 级重装必须生效(请求级复用不生效)。 + group.ProfitMinMargin = 0.1 + turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &groupID) + require.False(t, turnAt.Before(connAt)) + require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx)) + vetoed, _ = OpenAIProfitControlVeto(turnCtx, expensive) + require.False(t, vetoed, "turn 级重装应采用最新分组配置") + }) + + t.Run("keeps scheduled group of the existing gate", func(t *testing.T) { + scheduledGroupID := int64(64) + scheduled := profitControlTestGroup(scheduledGroupID, 0.5, 0) + connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(scheduled), &scheduledGroupID) + // 入口分组与调度分组不同(composite 成员分组场景):turn 重装取门的分组。 + entryGroupID := int64(65) + turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID) + gate, ok := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.NotNil(t, gate) + require.Equal(t, scheduledGroupID, gate.groupID) + }) + + t.Run("suppress marker only refreshes instant", func(t *testing.T) { + base := WithOpenAIProfitControlSuppressed(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0))) + turnCtx, turnAt := svc.WithOpenAITurnPricingContext(base, &groupID) + require.False(t, turnAt.IsZero()) + vetoed, _ := OpenAIProfitControlVeto(turnCtx, expensive) + require.False(t, vetoed) + }) + + t.Run("clears gate when group disables profit control mid-connection", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.5, 0) + connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID) + group.ProfitControlEnabled = false + turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &groupID) + vetoed, _ := OpenAIProfitControlVeto(turnCtx, expensive) + require.False(t, vetoed, "关门后 turn 级复核应放行") + }) +} + +// 无门时准入后绑定回退官方 eager 语义:等待/抢槽路径不得因利润控制关闭而 +// 失去粘性绑定(评审 M-Bind 回归锚点)。 +func TestOpenAIProfitControlAfterAdmissionBindEagerWithoutGate(t *testing.T) { + groupID := int64(82) + expensiveID := int64(903) + cheapID := int64(904) + const sessionHash = "no-gate-sticky" + const cacheKey = "openai:" + sessionHash + cache := &schedulerTestGatewayCache{ + sessionBindings: map[string]int64{cacheKey: expensiveID}, + } + svc := &OpenAIGatewayService{cache: cache} + + require.NoError(t, svc.BindStickySessionAfterProfitAdmission(context.Background(), &groupID, sessionHash, cheapID)) + require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无门时保持既有 eager 绑定行为") +} diff --git a/backend/internal/service/openai_profit_control_test.go b/backend/internal/service/openai_profit_control_test.go new file mode 100644 index 0000000000..c0fb51e64d --- /dev/null +++ b/backend/internal/service/openai_profit_control_test.go @@ -0,0 +1,311 @@ +package service + +import ( + "context" + "errors" + "math" + + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +func profitControlTestGroup(id int64, margin, buffer float64) *Group { + return &Group{ + ID: id, + Platform: PlatformOpenAI, + Status: StatusActive, + Hydrated: true, + RateMultiplier: 1.0, + SubscriptionType: SubscriptionTypeStandard, + ProfitControlEnabled: true, + ProfitMinMargin: margin, + ProfitSafetyBuffer: buffer, + } +} + +func profitControlTestCtx(group *Group) context.Context { + return context.WithValue(context.Background(), ctxkey.Group, group) +} + +func profitControlTestAccountWithRate(account *Account, rate float64) *Account { + account.RateMultiplier = &rate + return account +} + +func TestResolveOpenAIProfitControlGate(t *testing.T) { + svc := &OpenAIGatewayService{} + groupID := int64(7) + + t.Run("nil group id yields no gate", func(t *testing.T) { + require.Nil(t, svc.resolveOpenAIProfitControlGate(context.Background(), nil)) + }) + + t.Run("no ctx group and no snapshot yields no gate", func(t *testing.T) { + require.Nil(t, svc.resolveOpenAIProfitControlGate(context.Background(), &groupID)) + }) + + t.Run("disabled group yields no gate", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.3, 0) + group.ProfitControlEnabled = false + require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) + }) + + t.Run("non openai or grok platform yields no gate even if enabled", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.3, 0) + group.Platform = PlatformAnthropic + require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) + }) + + t.Run("grok group routed through openai handler installs gate", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.3, 0.05) + group.Platform = PlatformGrok + group.RateMultiplier = 0.5 + gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + require.NotNil(t, gate) + require.Equal(t, PlatformGrok, gate.platform) + require.InDelta(t, 0.5*(1-0.35), gate.threshold, 1e-12) + }) + + t.Run("ctx group id mismatch without snapshot yields no gate", func(t *testing.T) { + group := profitControlTestGroup(groupID+1, 0.3, 0) + require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) + }) + + t.Run("threshold composes margin and buffer from downstream rate", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.3, 0.05) + group.RateMultiplier = 2.0 + gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + require.NotNil(t, gate) + require.InDelta(t, 2.0*(1-0.35), gate.threshold, 1e-12) + require.Equal(t, PlatformOpenAI, gate.platform) + require.False(t, gate.pricingAt.IsZero()) + require.Equal(t, groupID, gate.groupID) + }) + + t.Run("threshold applies peak factor exactly like billing", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.5, 0) + group.SubscriptionType = SubscriptionTypeSubscription + group.PeakRateEnabled = true + group.PeakStart = "00:00" + group.PeakEnd = "23:59" + group.PeakRateMultiplier = 3.0 + gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + require.NotNil(t, gate) + expected := group.RateMultiplier * group.PeakMultiplierAt(timezone.Now()) * 0.5 + require.InDelta(t, expected, gate.threshold, 1e-9) + require.Equal(t, PlatformOpenAI, gate.platform) + }) +} + +func TestOpenAIProfitControlVetoReason(t *testing.T) { + now := time.Now() + gateCtx := func(threshold float64) context.Context { + return context.WithValue(context.Background(), openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{ + threshold: threshold, + pricingAt: now, + }) + } + + t.Run("no gate admits everything", func(t *testing.T) { + vetoed, reason := openAIProfitControlVetoReason(context.Background(), upstreamCostTestOAuthAccount(1)) + require.False(t, vetoed) + require.Empty(t, reason) + }) + + t.Run("fresh rate below threshold admits", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 99, now.Add(-time.Minute), 30*time.Minute), 0.5) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.False(t, vetoed) + }) + + t.Run("rate exactly at threshold admits via epsilon", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 99, now.Add(-time.Minute), 30*time.Minute), 0.7) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.False(t, vetoed) + }) + + t.Run("rate within float noise above threshold admits", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 99, now.Add(-time.Minute), 30*time.Minute), 0.7+1e-12) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.False(t, vetoed) + }) + + t.Run("rate above threshold is vetoed", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.1, now.Add(-time.Minute), 30*time.Minute), 0.8) + vetoed, reason := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + }) + + t.Run("zero threshold only admits free upstream", func(t *testing.T) { + free := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 99, now.Add(-time.Minute), 30*time.Minute), 0) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0), free) + require.False(t, vetoed) + paid := profitControlTestAccountWithRate(upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0, now.Add(-time.Minute), 30*time.Minute), 0.01) + vetoed, reason := openAIProfitControlVetoReason(gateCtx(0), paid) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + }) + + t.Run("missing account rate is invalid", func(t *testing.T) { + vetoed, reason := openAIProfitControlVetoReason(gateCtx(0.7), upstreamCostTestOAuthAccount(1)) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason) + }) + + t.Run("oauth account with manual rate is priceable", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(1), 0.2) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.False(t, vetoed) + }) + + t.Run("stale probe does not affect manual account rate", func(t *testing.T) { + account := profitControlTestAccountWithRate(upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 99, now.Add(-3*time.Hour), 30*time.Minute), 0.1) + vetoed, _ := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.False(t, vetoed) + }) + + t.Run("negative and non-finite rates are invalid", func(t *testing.T) { + for _, rate := range []float64{-1, math.NaN(), math.Inf(1)} { + account := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(1), rate) + vetoed, reason := openAIProfitControlVetoReason(gateCtx(0.7), account) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason) + } + }) +} + +func TestProfitControlSchedulerFiltersCandidates(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + + now := time.Now() + cheap := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) + expensive := upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) + oauth := upstreamCostTestOAuthAccount(3) + profitControlTestAccountWithRate(cheap, 0.3) + profitControlTestAccountWithRate(expensive, 0.8) + for _, account := range []*Account{cheap, expensive, oauth} { + account.Status = StatusActive + account.Schedulable = true + account.Concurrency = 5 + } + cache := &upstreamCostTrackingConcurrencyCache{loadMap: map[int64]*AccountLoadInfo{ + cheap.ID: {AccountID: cheap.ID}, + expensive.ID: {AccountID: expensive.ID}, + oauth.ID: {AccountID: oauth.ID}, + }} + cfg := &config.Config{} + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive, *oauth}}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(cache), + } + groupID := int64(7) + + t.Run("unprofitable and invalid-rate accounts never win", func(t *testing.T) { + // margin 0.5 → 阈值 0.5:expensive(0.8) 超阈值、oauth 倍率缺失,仅 cheap 可选。 + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, cheap.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + } + }) + + t.Run("all excluded surfaces standard no-available error with profit reasons", func(t *testing.T) { + // margin+buffer 0.8 → 阈值 0.2:cheap/expensive 超阈值,oauth 倍率非法。 + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.Nil(t, selection) + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=2") + require.Contains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate+"=1") + }) + + t.Run("manually rated oauth account is admitted", func(t *testing.T) { + // 阈值 0.2 排除两个 API Key;OAuth 手工倍率 0.1 可参与调度。 + profitControlTestAccountWithRate(oauth, 0.1) + svc.accountRepo = schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive, *oauth}} + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.Equal(t, oauth.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + + t.Run("gate disabled keeps official behavior", func(t *testing.T) { + group := profitControlTestGroup(groupID, 0.7, 0.1) + group.ProfitControlEnabled = false + selection, _, err := svc.SelectAccountWithScheduler(profitControlTestCtx(group), &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) +} + +func TestValidateProfitControlConfig(t *testing.T) { + require.NoError(t, ValidateProfitControlConfig(PlatformAnthropic, false, 0, 0)) + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity} { + require.NoError(t, ValidateProfitControlConfig(platform, true, 0.3, 0.05)) + require.NoError(t, ValidateProfitControlConfig(platform, true, 0, 0)) + } + + require.Error(t, ValidateProfitControlConfig(PlatformComposite, true, 0.3, 0)) + require.Error(t, ValidateProfitControlConfig(PlatformOpenAI, true, -0.1, 0)) + require.Error(t, ValidateProfitControlConfig(PlatformOpenAI, true, 1.0, 0)) + require.Error(t, ValidateProfitControlConfig(PlatformOpenAI, true, 0, 1.0)) + require.Error(t, ValidateProfitControlConfig(PlatformOpenAI, true, 0.6, 0.4)) +} + +func TestNormalizeProfitControlConfig(t *testing.T) { + t.Run("unsupported platform resets everything", func(t *testing.T) { + enabled, margin, buffer := NormalizeProfitControlConfig(PlatformComposite, true, 0.3, 0.1) + require.False(t, enabled) + require.Zero(t, margin) + require.Zero(t, buffer) + }) + + t.Run("all five platforms retain configuration", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformGrok, PlatformAntigravity} { + enabled, margin, buffer := NormalizeProfitControlConfig(platform, true, 0.3, 0.1) + require.True(t, enabled) + require.InDelta(t, 0.3, margin, 1e-12) + require.InDelta(t, 0.1, buffer, 1e-12) + } + }) + + t.Run("openai disabled keeps legal values and cleans dirty ones", func(t *testing.T) { + enabled, margin, buffer := NormalizeProfitControlConfig(PlatformOpenAI, false, 0.3, 0.05) + require.False(t, enabled) + require.InDelta(t, 0.3, margin, 1e-12) + require.InDelta(t, 0.05, buffer, 1e-12) + + _, margin, buffer = NormalizeProfitControlConfig(PlatformOpenAI, false, -1, 1.5) + require.Zero(t, margin) + require.Zero(t, buffer) + }) + + t.Run("openai enabled passes through for validation", func(t *testing.T) { + enabled, margin, buffer := NormalizeProfitControlConfig(PlatformOpenAI, true, 0.3, 0.05) + require.True(t, enabled) + require.InDelta(t, 0.3, margin, 1e-12) + require.InDelta(t, 0.05, buffer, 1e-12) + }) +} diff --git a/backend/internal/service/openai_proxy_stream_circuit.go b/backend/internal/service/openai_proxy_stream_circuit.go index a28a005143..575fc134bb 100644 --- a/backend/internal/service/openai_proxy_stream_circuit.go +++ b/backend/internal/service/openai_proxy_stream_circuit.go @@ -15,20 +15,32 @@ const ( defaultOpenAIProxyStreamFailureWindow = time.Minute defaultOpenAIProxyStreamQuarantineTTL = 10 * time.Minute defaultOpenAIProxyStreamCircuitMaxEntries = 4096 + // defaultOpenAIProxyStreamFailureCollapse merges disconnects that land + // within this interval into a single failure event. One proxy or HTTP/2 + // connection loss kills every stream multiplexed on it at the same + // moment; counting each stream separately would trip the breaker from a + // single upstream event (#5056). + defaultOpenAIProxyStreamFailureCollapse = 3 * time.Second + // openAIProxyStreamFailOpenLogInterval rate-limits the fail-open warning + // so an outage-mode burst does not flood the log. + openAIProxyStreamFailOpenLogInterval = 5 * time.Second ) type openAIProxyStreamCircuitSettings struct { + disabled bool failureThreshold int failureWindow time.Duration quarantineTTL time.Duration + collapseInterval time.Duration maxEntries int } type openAIProxyStreamCircuitEntry struct { - failureCount int - windowStart time.Time - blockedUntil time.Time - lastTouched time.Time + failureCount int + windowStart time.Time + lastFailureAt time.Time + blockedUntil time.Time + lastTouched time.Time } // openAIProxyStreamCircuit is an in-process, proxy-ID keyed circuit. It is @@ -45,12 +57,14 @@ func resolveOpenAIProxyStreamCircuitSettings(s *OpenAIGatewayService) openAIProx failureThreshold: defaultOpenAIProxyStreamFailureThreshold, failureWindow: defaultOpenAIProxyStreamFailureWindow, quarantineTTL: defaultOpenAIProxyStreamQuarantineTTL, + collapseInterval: defaultOpenAIProxyStreamFailureCollapse, maxEntries: defaultOpenAIProxyStreamCircuitMaxEntries, } if s == nil || s.cfg == nil { return settings } cfg := s.cfg.Gateway.OpenAIProxyStreamCircuit + settings.disabled = cfg.Disabled if cfg.FailureThreshold > 0 { settings.failureThreshold = cfg.FailureThreshold } @@ -76,6 +90,9 @@ func newOpenAIProxyStreamCircuit(settings openAIProxyStreamCircuitSettings) *ope if settings.maxEntries <= 0 { settings.maxEntries = defaultOpenAIProxyStreamCircuitMaxEntries } + if settings.collapseInterval < 0 { + settings.collapseInterval = 0 + } return &openAIProxyStreamCircuit{ settings: settings, entries: make(map[int64]openAIProxyStreamCircuitEntry), @@ -95,7 +112,7 @@ func (s *OpenAIGatewayService) getOpenAIProxyStreamCircuit() *openAIProxyStreamC } func (c *openAIProxyStreamCircuit) recordFailure(proxyID int64, now time.Time) (bool, time.Time) { - if c == nil || proxyID <= 0 { + if c == nil || c.settings.disabled || proxyID <= 0 { return false, time.Time{} } c.mu.Lock() @@ -115,7 +132,17 @@ func (c *openAIProxyStreamCircuit) recordFailure(proxyID int64, now time.Time) ( entry.windowStart = now entry.blockedUntil = time.Time{} } + // Collapse a burst of disconnects into one failure event: when a proxy or + // a multiplexed HTTP/2 connection dies, every in-flight stream reports the + // same underlying incident within moments of each other. + if c.settings.collapseInterval > 0 && !entry.lastFailureAt.IsZero() && + now.Sub(entry.lastFailureAt) >= 0 && now.Sub(entry.lastFailureAt) < c.settings.collapseInterval { + entry.lastTouched = now + c.entries[proxyID] = entry + return false, time.Time{} + } entry.failureCount++ + entry.lastFailureAt = now entry.lastTouched = now tripped := entry.failureCount >= c.settings.failureThreshold if tripped { @@ -139,7 +166,7 @@ func (c *openAIProxyStreamCircuit) recordSuccess(proxyID int64) bool { } func (c *openAIProxyStreamCircuit) isBlocked(proxyID int64, now time.Time) bool { - if c == nil || proxyID <= 0 { + if c == nil || c.settings.disabled || proxyID <= 0 { return false } c.mu.Lock() @@ -155,6 +182,25 @@ func (c *openAIProxyStreamCircuit) isBlocked(proxyID int64, now time.Time) bool return true } +// activeBlockCount reports how many proxies are currently quarantined. It +// gates the fail-open retry: a "no available accounts" selection result only +// warrants a second, quarantine-blind pass when the circuit is actually +// withholding capacity. +func (c *openAIProxyStreamCircuit) activeBlockCount(now time.Time) int { + if c == nil || c.settings.disabled { + return 0 + } + c.mu.Lock() + defer c.mu.Unlock() + count := 0 + for _, entry := range c.entries { + if !entry.blockedUntil.IsZero() && now.Before(entry.blockedUntil) { + count++ + } + } + return count +} + func (c *openAIProxyStreamCircuit) ensureCapacityLocked(now time.Time) { if len(c.entries) < c.settings.maxEntries { return @@ -219,11 +265,48 @@ func (s *OpenAIGatewayService) clearOpenAIProxyStreamDisconnect(account *Account } } -func (s *OpenAIGatewayService) isOpenAIProxyStreamQuarantined(account *Account) bool { +// openAIProxyStreamQuarantineBypassKey marks a selection pass that must ignore +// proxy quarantine. It is set for the second, fail-open selection attempt when +// the first pass found no available account while the circuit was withholding +// proxies: a degraded proxy is strictly better than answering 502 (#5056). +type openAIProxyStreamQuarantineBypassKey struct{} + +func withOpenAIProxyStreamQuarantineBypass(ctx context.Context) context.Context { + return context.WithValue(ctx, openAIProxyStreamQuarantineBypassKey{}, true) +} + +func openAIProxyStreamQuarantineBypassed(ctx context.Context) bool { + if ctx == nil { + return false + } + bypassed, _ := ctx.Value(openAIProxyStreamQuarantineBypassKey{}).(bool) + return bypassed +} + +func (s *OpenAIGatewayService) isOpenAIProxyStreamQuarantined(ctx context.Context, account *Account) bool { proxyID, ok := openAIProxyStreamCircuitProxyID(account) if !ok { return false } + if openAIProxyStreamQuarantineBypassed(ctx) { + return false + } circuit := s.getOpenAIProxyStreamCircuit() return circuit != nil && circuit.isBlocked(proxyID, time.Now()) } + +// logOpenAIProxyStreamQuarantineFailOpen emits a rate-limited warning when a +// selection pass had to re-admit quarantined proxies to serve at all. +func (s *OpenAIGatewayService) logOpenAIProxyStreamQuarantineFailOpen(requestedModel string, blockedProxies int) { + now := time.Now().UnixNano() + last := s.openaiProxyStreamFailOpenLogAt.Load() + if now-last < int64(openAIProxyStreamFailOpenLogInterval) || + !s.openaiProxyStreamFailOpenLogAt.CompareAndSwap(last, now) { + return + } + logger.L().With(zap.String("component", "service.openai_gateway")).Warn( + "openai.proxy_stream_quarantine_fail_open", + zap.Int("blocked_proxies", blockedProxies), + zap.String("model", requestedModel), + ) +} diff --git a/backend/internal/service/openai_proxy_stream_circuit_test.go b/backend/internal/service/openai_proxy_stream_circuit_test.go index c278ea8c1e..76a07f9eae 100644 --- a/backend/internal/service/openai_proxy_stream_circuit_test.go +++ b/backend/internal/service/openai_proxy_stream_circuit_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "time" @@ -35,6 +36,82 @@ func TestOpenAIProxyStreamCircuitThresholdTTLAndSuccessReset(t *testing.T) { require.False(t, tripped, "failures outside the window must not accumulate") } +func TestOpenAIProxyStreamCircuitCollapsesBurstFailures(t *testing.T) { + base := time.Unix(1_800_000_000, 0) + circuit := newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 2, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + collapseInterval: 3 * time.Second, + maxEntries: 16, + }) + + // One HTTP/2 connection loss kills several multiplexed streams at once: + // the near-simultaneous reports must count as a single failure event. + tripped, _ := circuit.recordFailure(1, base) + require.False(t, tripped) + tripped, _ = circuit.recordFailure(1, base.Add(time.Second)) + require.False(t, tripped, "burst failures inside the collapse interval must merge") + tripped, _ = circuit.recordFailure(1, base.Add(2*time.Second)) + require.False(t, tripped, "burst failures inside the collapse interval must merge") + require.False(t, circuit.isBlocked(1, base.Add(2*time.Second))) + + // A second, distinct incident past the collapse interval still trips. + tripped, _ = circuit.recordFailure(1, base.Add(5*time.Second)) + require.True(t, tripped) + require.True(t, circuit.isBlocked(1, base.Add(5*time.Second))) +} + +func TestOpenAIProxyStreamCircuitDisabled(t *testing.T) { + base := time.Unix(1_800_000_000, 0) + circuit := newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + disabled: true, + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) + + tripped, _ := circuit.recordFailure(1, base) + require.False(t, tripped) + require.False(t, circuit.isBlocked(1, base)) + require.Equal(t, 0, circuit.activeBlockCount(base)) +} + +func TestOpenAIProxyStreamCircuitActiveBlockCount(t *testing.T) { + base := time.Unix(1_800_000_000, 0) + circuit := newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) + + require.Equal(t, 0, circuit.activeBlockCount(base)) + tripped, until := circuit.recordFailure(1, base) + require.True(t, tripped) + circuit.recordFailure(2, base) // second proxy also tripped (threshold 1) + require.Equal(t, 2, circuit.activeBlockCount(base.Add(time.Second))) + require.Equal(t, 0, circuit.activeBlockCount(until), "expired quarantines must not count") +} + +func TestOpenAIProxyStreamQuarantineBypassContext(t *testing.T) { + proxyID := int64(7) + account := &Account{ID: 1, Platform: PlatformOpenAI, ProxyID: &proxyID} + svc := &OpenAIGatewayService{} + svc.openaiProxyStreamCircuit = newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ + failureThreshold: 1, + failureWindow: time.Minute, + quarantineTTL: 10 * time.Minute, + maxEntries: 16, + }) + svc.openaiProxyStreamCircuit.recordFailure(proxyID, time.Now()) + + ctx := context.Background() + require.True(t, svc.isOpenAIProxyStreamQuarantined(ctx, account)) + require.False(t, svc.isOpenAIProxyStreamQuarantined(withOpenAIProxyStreamQuarantineBypass(ctx), account)) +} + func TestOpenAIProxyStreamCircuitBoundsEntries(t *testing.T) { base := time.Unix(1_800_000_000, 0) circuit := newOpenAIProxyStreamCircuit(openAIProxyStreamCircuitSettings{ diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index c4bad643e7..58bf4659f5 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -34,6 +34,7 @@ const ( openaiQuotaSecFetchSite = "none" openaiQuotaSecFetchMode = "no-cors" openaiQuotaSecFetchDest = "empty" + openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot" ) // OpenAIRateLimitWindow describes a single rate-limit window returned by @@ -206,6 +207,37 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* return &payload, nil } +// CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an +// explicit UI refresh. The snapshot is written to the account that was queried +// (for a spark shadow that is the shadow row, even though the credits belong to +// its parent) because it is a per-row display cache: each row caches exactly +// what its own card renders, and shadows cannot consume credits anyway. +// +// Missing expiration details leave the old cache intact: +// a snapshot claiming N>0 available credits without their expiration timestamps +// cannot be aged out by readers, so it would keep showing (and offering to +// consume) credits that already expired. Callers must treat this rejection as a +// partial success — the upstream read itself is still valid. +func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error { + if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) { + return infraerrors.New( + http.StatusBadGateway, + "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED", + "failed to refresh reset-credit expiration details; cached data was preserved", + ) + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ + openaiQuotaResetCreditsKey: credits, + }); err != nil { + return infraerrors.New( + http.StatusInternalServerError, + "OPENAI_QUOTA_CACHE_WRITE_FAILED", + "failed to cache reset-credit details", + ).WithCause(err) + } + return nil +} + func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails { quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP) if headerErr != nil { diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index ad871c3f2e..a351381cb4 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -27,7 +27,9 @@ import ( // stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。 type stubQuotaAccountRepo struct { AccountRepository - accounts map[int64]*Account + accounts map[int64]*Account + extraUpdates map[int64]map[string]any + extraUpdateErr error } func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { @@ -47,6 +49,17 @@ func (r *stubQuotaAccountRepo) UpdateCredentials(_ context.Context, id int64, cr return nil } +func (r *stubQuotaAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + if r.extraUpdateErr != nil { + return r.extraUpdateErr + } + if r.extraUpdates == nil { + r.extraUpdates = make(map[int64]map[string]any) + } + r.extraUpdates[id] = updates + return nil +} + // stubQuotaTokenCache 实现 OpenAITokenCache,返回预设静态 token。 type stubQuotaTokenCache struct { tokens map[string]string @@ -535,6 +548,14 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { {ExpiresAt: "2026-07-03T04:05:06Z"}, {ExpiresAt: "2026-07-04T04:05:06Z"}, }, usage.RateLimitResetCredits.Credits) + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits)) + require.Equal(t, &OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{ + {ExpiresAt: "2026-07-03T04:05:06Z"}, + {ExpiresAt: "2026-07-04T04:05:06Z"}, + }, + }, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) encoded, err := json.Marshal(usage) require.NoError(t, err) @@ -584,6 +605,67 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount) require.Equal(t, 1, detailCalls) require.Empty(t, usage.RateLimitResetCredits.Credits) + + // A count without expiration details must not be persisted (the reader could + // never age it out), and the previous snapshot must survive untouched. + require.Error(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits)) + require.Empty(t, repo.extraUpdates) +} + +func TestCacheResetCreditsSnapshot(t *testing.T) { + ctx := context.Background() + + t.Run("zero count allows an empty expiration list", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + credits := &OpenAIRateLimitResetCredits{AvailableCount: 0} + + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, credits)) + require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + }) + + t.Run("missing expiration list preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{AvailableCount: 1}) + + require.Error(t, err) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("empty expiration list with a positive count preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{}, + }) + + require.Error(t, err) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("nil snapshot preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + require.Error(t, svc.CacheResetCreditsSnapshot(ctx, 100, nil)) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("repository errors are returned", func(t *testing.T) { + repo := &stubQuotaAccountRepo{extraUpdateErr: errors.New("database unavailable")} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: "2026-07-03T04:05:06Z"}}, + }) + + require.ErrorContains(t, err, "database unavailable") + }) } // TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义: diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 92048430d5..73453c2260 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "strings" @@ -15,6 +16,13 @@ const ( var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +type openAIReasoningEffortPolicyContextKey struct{} + +type openAIReasoningEffortPolicy struct { + maxEffort string + mappings []ReasoningEffortMapping +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -41,7 +49,7 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI { + if platform != PlatformOpenAI && platform != PlatformComposite { return nil } return openAIReasoningEffortValues @@ -54,7 +62,11 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { - return "", fmt.Errorf("reasoning effort policy is only supported for platform %q", PlatformOpenAI) + return "", fmt.Errorf( + "reasoning effort policy is only supported for platforms %q and %q", + PlatformOpenAI, + PlatformComposite, + ) } value := NormalizeMaxReasoningEffort(raw) @@ -91,7 +103,7 @@ func reasoningEffortRank(raw string) (int, bool) { } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI groups. +// fixed effort values supported by OpenAI routes. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { if len(raw) > maxReasoningEffortMappings { return nil, fmt.Errorf("reasoning effort mappings cannot exceed %d entries", maxReasoningEffortMappings) @@ -124,6 +136,33 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp return normalized, nil } +// WithOpenAIReasoningEffortPolicy binds a group policy to a request after its +// concrete target platform has been resolved to OpenAI. The policy is copied so +// retries and asynchronous forwarding cannot observe later slice mutations. +func WithOpenAIReasoningEffortPolicy(ctx context.Context, maxEffort string, mappings []ReasoningEffortMapping) context.Context { + if ctx == nil { + ctx = context.Background() + } + policy := openAIReasoningEffortPolicy{ + maxEffort: maxEffort, + mappings: append([]ReasoningEffortMapping(nil), mappings...), + } + return context.WithValue(ctx, openAIReasoningEffortPolicyContextKey{}, policy) +} + +// ApplyOpenAIReasoningEffortPolicyFromContext applies a policy previously bound +// to the request. An unbound request is returned byte-for-byte unchanged. +func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byte) ([]byte, bool) { + if ctx == nil { + return body, false + } + policy, ok := ctx.Value(openAIReasoningEffortPolicyContextKey{}).(openAIReasoningEffortPolicy) + if !ok { + return body, false + } + return ApplyOpenAIReasoningEffortPolicy(body, policy.maxEffort, policy.mappings) +} + func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping) (string, bool) { value := strings.TrimSpace(raw) canonical := NormalizeMaxReasoningEffort(value) diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index a7ab7b2ad5..4b501f48db 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -28,15 +29,17 @@ func TestNormalizeMaxReasoningEffort(t *testing.T) { func TestNormalizeReasoningEffortMappings(t *testing.T) { t.Run("canonicalizes fixed OpenAI values", func(t *testing.T) { - got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ - {From: " MAX ", To: " x-high "}, - {From: "minimal", To: "high"}, - }) - require.NoError(t, err) - require.Equal(t, []ReasoningEffortMapping{ - {From: "max", To: "xhigh"}, - {From: "minimal", To: "high"}, - }, got) + for _, platform := range []string{PlatformOpenAI, PlatformComposite} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{ + {From: " MAX ", To: " x-high "}, + {From: "minimal", To: "high"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + {From: "minimal", To: "high"}, + }, got) + } }) t.Run("rejects empty values", func(t *testing.T) { @@ -55,7 +58,7 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platform \"openai\"") + require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") } _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) @@ -70,16 +73,34 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { value, err := normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "max") require.NoError(t, err) require.Equal(t, "max", value) + value, err = normalizeMaxReasoningEffortForPlatform(PlatformComposite, "max") + require.NoError(t, err) + require.Equal(t, "max", value) for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platform \"openai\"") + require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") require.ErrorContains(t, err, "not supported") } +func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { + body := []byte(`{"reasoning":{"effort":"max"}}`) + + unbound, changed := ApplyOpenAIReasoningEffortPolicyFromContext(context.Background(), body) + require.False(t, changed) + require.Equal(t, body, unbound) + + mappings := []ReasoningEffortMapping{{From: "max", To: "xhigh"}} + ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", mappings) + mappings[0].To = "low" + got, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, body) + require.True(t, changed) + require.Equal(t, "medium", gjson.GetBytes(got, "reasoning.effort").String()) +} + func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go index 27b7dea0f5..34443944b2 100644 --- a/backend/internal/service/openai_responses_item_id.go +++ b/backend/internal/service/openai_responses_item_id.go @@ -17,6 +17,9 @@ func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool { if itemType == "message" { return !strings.HasPrefix(id, "msg") } + if itemType == "reasoning" { + return !strings.HasPrefix(id, "rs") + } if isCodexToolCallInputType(itemType) { return !strings.HasPrefix(id, "fc") } diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index bd548b69ec..73760f567b 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" @@ -14,14 +15,39 @@ import ( const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names" // shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平 -// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口 -// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的 -// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace -// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 -func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool { +// Codex namespace 工具。 +// +// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses +// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的 +// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs +// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP +// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer +// 消息里写死的 `to=functions..` 寻址约定,模型据此寻址必然落空 +// (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、 +// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。 +// +// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报 +// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测 +// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此 +// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。 +// +// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留 +// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口 +// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名 +// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样; +// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 +func shouldFlattenOpenAIResponsesNamespaces( + account *Account, + transport OpenAIUpstreamTransport, + passthroughEnabled bool, + compactPath bool, +) bool { if account == nil || !account.IsOpenAIOAuth() { return false } + if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() { + return false + } if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { return false } @@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA return true } +// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否 +// 保留工具调用项上的 namespace。 +// +// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容: +// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400 +// `Missing namespace for function_call '...'. Round-trip the model's +// function_call item with its namespace field included.`(issue #4761 回帖), +// 故 OAuth 非 compact 请求必须保留。 +// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter: +// input[N].namespace`(issue #4761 正文),故 compact 一律清理。 +// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样 +// 不认识该字段,维持全量清理;否则只能退化成 +// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。 +// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。 +func shouldKeepOpenAIResponsesToolCallNamespaces( + account *Account, + transport OpenAIUpstreamTransport, + passthroughEnabled bool, + compactPath bool, +) bool { + if account == nil || !account.IsOpenAIOAuth() { + return false + } + if compactPath { + return false + } + return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath) +} + +// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与 +// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs +// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace, +// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。 +var openAIResponsesToolCallItemTypes = map[string]bool{ + "function_call": true, + "tool_call": true, + "custom_tool_call": true, + "mcp_tool_call": true, +} + +func isOpenAIResponsesToolCallItemType(itemType string) bool { + return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))] +} + func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil @@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro // array items. Namespace declarations and nested namespace fields are left // untouched. Rebuilding the input array once keeps this linear for long // histories and avoids decoding JSON numbers through float64. -func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { +// +// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的 +// namespace,让 Codex 调用能按上游要求原样回传;判定见 +// shouldKeepOpenAIResponsesToolCallNamespaces。 +func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil } @@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { } first = false itemBody := []byte(item.Raw) - if item.IsObject() && item.Get("namespace").Exists() { + // 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace + // 的项,这样它们无需再扫一次 type。 + if item.IsObject() && item.Get("namespace").Exists() && + (!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) { itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace") if stripErr != nil { return false @@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { _, _ = rebuilt.Write(itemBody) return true }) + _ = rebuilt.WriteByte(']') if stripErr != nil { return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr) } if !changed { return body, nil } - _ = rebuilt.WriteByte(']') stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes()) if err != nil { return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err) @@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat } } +// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的 +// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的 +// 账号拿着上一个账号的摊平名做回程还原。 +func clearOpenAIResponsesNamespaceNames(c *gin.Context) { + if c == nil { + return + } + if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists { + c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil)) + } +} + func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName { if c == nil { return nil diff --git a/backend/internal/service/openai_responses_namespace_forward_test.go b/backend/internal/service/openai_responses_namespace_forward_test.go new file mode 100644 index 0000000000..e99d0199fd --- /dev/null +++ b/backend/internal/service/openai_responses_namespace_forward_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 + +// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。 +const codexNamespaceRequestBody = `{ + "model":"gpt-5.6-terra", + "stream":false, + "instructions":"test", + "tools":[ + {"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[ + {"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object"}} + ]}, + {"type":"function","name":"exec","parameters":{"type":"object"}} + ], + "input":[ + {"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"}, + {"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]} + ] +}` + +const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}` + +// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留 +// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的 +// 残留 namespace 仍要清掉。回归 issue #4978。 +func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`) + require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发") + require.Equal(t, "collaboration", namespaceTool.Get("name").String()) + require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String()) + require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String()) + // 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。 + require.NotContains(t, string(forwarded), "collaboration__spawn_agent") + + require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + + // 未摊平即无需回程还原,不得登记映射。 + require.Empty(t, openAIResponsesNamespaceNames(c)) +} + +// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761), +// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具, +// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。 +func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + c.Request.URL.Path = "/v1/responses/compact" + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) +} + +// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。 +func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + account := newOpenAIOAuthNamespaceTestAccount() + account.Extra = map[string]any{"openai_responses_flatten_namespaces": true} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, account, body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists()) + require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists()) + require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists()) + // 摊平后调用项已改写成平名,不得再带 namespace。 + require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) + require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + + names := openAIResponsesNamespaceNames(c) + require.Equal(t, + apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, + names["collaboration__spawn_agent"], + ) +} + +// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号 +// 不得沿用上一个账号登记的摊平名映射做回程还原。 +func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{ + "stale__tool": {Namespace: "stale", Name: "tool"}, + }) + + _, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.Empty(t, openAIResponsesNamespaceNames(c)) +} diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 0b12355a3d..80095f3293 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} + // 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。 + flattenOAuth := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } + flattenAPIKey := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } tests := []struct { name string account *Account transport OpenAIUpstreamTransport passthroughEnabled bool + compactPath bool want bool }{ - {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, - {name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, + // 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按 + // `to=functions..` 寻址(issue #4978)。 + {name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false}, + {name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + // compact 端点 schema 更窄且无实测证据,保持既有摊平行为。 + {name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true}, + {name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false}, + {name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false}, + {name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, // WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。 - {name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, - // 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 - {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + {name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + // 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。 + {name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + // 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。 + {name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled)) + require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces( + tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, + )) + }) + } +} + +func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken} + flattenOAuth := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } + + tests := []struct { + name string + account *Account + transport OpenAIUpstreamTransport + passthroughEnabled bool + compactPath bool + want bool + }{ + // 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。 + {name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, + // compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。 + {name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false}, + // 摊平后调用项已是平名,残留 namespace 指向的声明不存在。 + {name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + // WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。 + {name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true}, + // WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身, + // 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。 + {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: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces( + tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, + )) }) } } @@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) { ] }`) - stripped, err := stripOpenAIResponsesInputNamespaces(body) + stripped, err := stripOpenAIResponsesInputNamespaces(body, false) require.NoError(t, err) for index := 0; index < 8; index++ { require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists()) @@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin []byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`), } for _, body := range tests { - stripped, err := stripOpenAIResponsesInputNamespaces(body) - require.NoError(t, err) - require.Equal(t, body, stripped) + for _, keepToolCallNamespaces := range []bool{false, true} { + stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces) + require.NoError(t, err) + require.Equal(t, body, stripped) + } + } +} + +// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用, +// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。 +func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) { + body := []byte(`{ + "meta":9007199254740993, + "input":[ + {"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993}, + {"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"}, + {"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"}, + {"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"}, + {"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]}, + {"type":"function_call_output","namespace":"leftover","output":"ok"}, + {"type":"reasoning","namespace":"leftover"}, + {"type":"item","namespace":"leftover"} + ] + }`) + + stripped, err := stripOpenAIResponsesInputNamespaces(body, true) + require.NoError(t, err) + + require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String()) + require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String()) + require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String()) + require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String()) + for index := 4; index < 8; index++ { + require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists()) + } + // 大整数不得经 float64 往返。 + require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw) + require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw) + + // 类型比对不区分大小写与首尾空白。 + mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`) + keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true) + require.NoError(t, err) + require.Equal(t, mixedCase, keptMixedCase) + + // 全部为调用项时无改动,应原样返回。 + callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`) + unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true) + require.NoError(t, err) + require.Equal(t, callsOnly, unchanged) + + // 关闭保留时回到全量清理。 + strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false) + require.NoError(t, err) + for index := 0; index < 8; index++ { + require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists()) } } diff --git a/backend/internal/service/openai_responses_tool_schema.go b/backend/internal/service/openai_responses_tool_schema.go new file mode 100644 index 0000000000..8c7e7b8d18 --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema.go @@ -0,0 +1,127 @@ +package service + +import ( + "bytes" + "sort" + + "github.com/tidwall/gjson" +) + +const ( + // 工具定义在多轮历史里最多再嵌套一层 tools,留出余量后截断,避免畸形请求体 + // 造成无界递归。 + openAIResponsesToolSchemaMaxDepth = 4 + // JSON Schema 里 type 只能是字符串或字符串数组;显式 null 无论哪个方言都非法, + // 补成 object 与 upstream 对该工具的实际期望一致。 + openAIResponsesToolSchemaFallbackType = `"object"` + // 显式 null 在 JSON 里只有这一种字面量形态。 + openAIResponsesToolSchemaNullLiteral = "null" +) + +// openAIResponsesToolSchemaNullType 记录一处待修正的 null,用原始 body 上的 +// 绝对字节偏移表示,便于最后一次性拼接。 +type openAIResponsesToolSchemaNullType struct { + offset int + length int +} + +// 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),构造请求能塞进百万级命中。 +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 + }) +} + +// 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)}) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go new file mode 100644 index 0000000000..2dbc7fb600 --- /dev/null +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// issue #5364 的最小复现体:Codex Desktop 内置 automation_update 带 +// parameters.type = null,upstream 回 400 invalid_function_parameters。 +func TestSanitizeOpenAIResponsesToolParameterTypes_TopLevelFunctionTool(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.6-sol", + "input": "Reply with OK.", + "stream": false, + "tools": [ + { + "type": "function", + "name": "automation_update", + "description": "Update an automation.", + "parameters": {"type": null, "properties": {}} + } + ] + }`) + + 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()) + // 只改 type,工具其余定义原样保留。 + require.Equal(t, "automation_update", gjson.GetBytes(sanitized, "tools.0.name").String()) + require.Equal(t, "Update an automation.", gjson.GetBytes(sanitized, "tools.0.description").String()) + require.True(t, gjson.GetBytes(sanitized, "tools.0.parameters.properties").IsObject()) + // 请求体其余字段不受影响。 + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(sanitized, "model").String()) + require.Equal(t, "Reply with OK.", gjson.GetBytes(sanitized, "input").String()) +} + +// 合法 Schema 必须原样返回:changed=false 且字节不变,避免无谓重写打散 +// prompt cache 前缀。 +func TestSanitizeOpenAIResponsesToolParameterTypes_ValidSchemaUntouched(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"type":"object","properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) +} + +// 缺失 type 的 Schema 本身合法(等价于不约束),不得补写——补写会收窄客户端语义。 +func TestSanitizeOpenAIResponsesToolParameterTypes_MissingTypeNotInvented(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","name":"ok","parameters":{"properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.False(t, changed) + require.False(t, gjson.GetBytes(sanitized, "tools.0.parameters.type").Exists()) +} + +// 多轮历史:工具定义沉进 input 后,upstream 报错路径形如 +// input[N].tools[i].tools[j].parameters,两层都要修。 +func TestSanitizeOpenAIResponsesToolParameterTypes_NestedHistoryTools(t *testing.T) { + body := []byte(`{ + "input": [ + {"type": "message", "role": "user", "content": "hi"}, + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "codex_app", + "tools": [ + {"type": "function", "name": "noop", "parameters": {"type": "object"}}, + {"type": "function", "name": "automation_update", "parameters": {"type": null}} + ] + }, + {"type": "function", "name": "outer", "parameters": {"type": null}} + ] + } + ] + }`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + 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, "object", gjson.GetBytes(sanitized, "input.1.tools.0.tools.0.parameters.type").String()) + require.Equal(t, "hi", gjson.GetBytes(sanitized, "input.0.content").String()) +} + +// ChatCompletions 形态的工具({type:"function", function:{...}})同样可能出现在 +// Responses 请求里,见 normalizeCodexTools。 +func TestSanitizeOpenAIResponsesToolParameterTypes_ChatCompletionsShape(t *testing.T) { + body := []byte(`{"tools":[{"type":"function","function":{"name":"legacy","parameters":{"type":null}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.0.function.parameters.type").String()) + require.Equal(t, "legacy", gjson.GetBytes(sanitized, "tools.0.function.name").String()) +} + +// 索引映射:只有坏条目被改,前后兄弟条目按原下标保持不变。 +func TestSanitizeOpenAIResponsesToolParameterTypes_OnlyOffendingIndexRewritten(t *testing.T) { + body := []byte(`{"tools":[ + {"type":"function","name":"a","parameters":{"type":"object"}}, + {"type":"function","name":"b","parameters":{"type":null}}, + {"type":"function","name":"c","parameters":{"type":"object"}} + ]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "a", gjson.GetBytes(sanitized, "tools.0.name").String()) + require.Equal(t, "b", gjson.GetBytes(sanitized, "tools.1.name").String()) + require.Equal(t, "c", gjson.GetBytes(sanitized, "tools.2.name").String()) + require.Equal(t, "object", gjson.GetBytes(sanitized, "tools.1.parameters.type").String()) + require.Equal(t, 3, int(gjson.GetBytes(sanitized, "tools.#").Int())) +} + +// 畸形/非常规形态不得 panic,且一律按不变处理。 +func TestSanitizeOpenAIResponsesToolParameterTypes_MalformedShapesAreNoOps(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"empty_body", ``}, + {"no_tools", `{"model":"gpt-5.6-sol","input":"hi"}`}, + {"tools_null", `{"tools":null}`}, + {"tools_object", `{"tools":{"type":"function"}}`}, + {"tool_is_string", `{"tools":["freeform"]}`}, + {"parameters_is_string", `{"tools":[{"type":"function","parameters":"nope"}]}`}, + {"parameters_null", `{"tools":[{"type":"function","parameters":null}]}`}, + {"input_string", `{"input":"hi","tools":[]}`}, + {"input_item_not_object", `{"input":["hi"]}`}, + {"type_already_array", `{"tools":[{"type":"function","parameters":{"type":["object","null"]}}]}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes([]byte(tc.body)) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, tc.body, string(sanitized)) + }) + } +} + +// 递归深度守卫:超深嵌套只做截断,不递归到栈溢出,也不报错。 +func TestSanitizeOpenAIResponsesToolParameterTypes_DepthGuard(t *testing.T) { + tool := map[string]any{"type": "function", "name": "deep", "parameters": map[string]any{"type": nil}} + for i := 0; i < 12; i++ { + tool = map[string]any{"type": "namespace", "tools": []any{tool}} + } + body, err := json.Marshal(map[string]any{"tools": []any{tool}}) + require.NoError(t, err) + + require.NotPanics(t, func() { + _, _, sanitizeErr := sanitizeOpenAIResponsesToolParameterTypes(body) + require.NoError(t, sanitizeErr) + }) +} + +// 输出必须是合法 JSON,且除目标字段外与输入等价。 +func TestSanitizeOpenAIResponsesToolParameterTypes_OutputStaysValidJSON(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","tool_choice":"none","store":false,"tools":[{"type":"function","name":"automation_update","parameters":{"type":null,"properties":{}}}]}`) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + require.NoError(t, err) + require.True(t, changed) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(sanitized, &decoded)) + require.Equal(t, "gpt-5.5", decoded["model"]) + require.Equal(t, "none", decoded["tool_choice"]) + require.Equal(t, false, decoded["store"]) +} + +// 输入 body 是调用方持有的缓冲区(Forward 里 canonicalImageIntentBody 与它同源), +// 净化必须返回新切片,绝不能就地改写。 +func TestSanitizeOpenAIResponsesToolParameterTypes_DoesNotMutateInputBody(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","tools":[{"type":"function","name":"a","parameters":{"type":null}}]}`) + original := append([]byte(nil), body...) + + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, string(original), string(body), "调用方的 body 不得被就地改写") + require.NotEqual(t, string(original), string(sanitized)) +} + +func buildToolSchemaNullTypeBody(t *testing.T, hits int) []byte { + t.Helper() + tools := make([]any, 0, hits) + for i := 0; i < hits; i++ { + tools = append(tools, map[string]any{ + "type": "function", + "name": "automation_update", + "parameters": map[string]any{"type": nil, "properties": map[string]any{}}, + }) + } + body, err := json.Marshal(map[string]any{"model": "gpt-5.6-sol", "tools": tools}) + require.NoError(t, err) + return body +} + +// 复杂度守卫:重写次数必须与命中数无关。 +// +// 逐个 sjson.SetBytes 的写法每命中一处就重扫并全量拷贝一次文档,命中 N 处即 N 次 +// 全量拷贝;/v1/responses 的 body 上限是 gateway.max_body_size(默认 256MB), +// 构造请求可以塞进百万级命中,会被放大成 TB 级 memcpy。这里用分配次数锁死该行为: +// 命中数放大 500 倍,分配次数不得随之增长。 +func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits(t *testing.T) { + small := buildToolSchemaNullTypeBody(t, 4) + large := buildToolSchemaNullTypeBody(t, 2000) + + smallAllocs := testing.AllocsPerRun(2, func() { + _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(small) + }) + largeAllocs := testing.AllocsPerRun(2, func() { + _, _, _ = sanitizeOpenAIResponsesToolParameterTypes(large) + }) + + // 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。 + require.Less(t, largeAllocs, smallAllocs+40, + "分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs) + + // 同时确认大 body 的结果确实全部修好了。 + sanitized, changed, err := sanitizeOpenAIResponsesToolParameterTypes(large) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, 2000, int(gjson.GetBytes(sanitized, "tools.#").Int())) + gjson.GetBytes(sanitized, "tools").ForEach(func(_, tool gjson.Result) bool { + require.Equal(t, "object", tool.Get("parameters.type").String()) + return true + }) +} diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go new file mode 100644 index 0000000000..0a1cc928cd --- /dev/null +++ b/backend/internal/service/openai_routing_hint.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "go.uber.org/zap" + "golang.org/x/net/http/httpguts" +) + +const openAICodexRoutingHintHeader = "x-codex-routing-hint" + +// setOpenAICodexRoutingHint mirrors the Codex backend routing-hint contract for +// OpenAI OAuth requests. The request model must already be the final upstream +// slug and serviceTier must already reflect any local policy rewrite/filter. +func setOpenAICodexRoutingHint(headers http.Header, account *Account, model string, serviceTier string) { + if headers == nil { + return + } + + // The routing hint is gateway-owned. Strip every spelling before deciding + // whether to synthesize it so API-key/provider credential paths cannot pass + // 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() { + return + } + + model = strings.TrimSpace(model) + if model == "" || strings.ContainsAny(model, ";=") { + return + } + + // Codex treats "default" as an explicit standard-routing sentinel, not as a + // service tier sent to the backend. Fast follows the gateway's existing + // canonicalization and therefore becomes "priority"; flex stays "flex". + canonicalTier := normalizedOpenAIServiceTierValue(serviceTier) + // This backport has no Codex model-catalog snapshot with which to validate + // arbitrary tier ids. Keep the hint to the two effective tiers Codex itself + // selects; default, missing, and other gateway-compatible API values remain + // model-only rather than expanding the ChatGPT routing protocol here. + switch canonicalTier { + case OpenAIFastTierPriority, OpenAIFastTierFlex: + default: + canonicalTier = "" + } + + hint := "model=" + model + if canonicalTier != "" { + hint += ";tier=" + canonicalTier + } + if !httpguts.ValidHeaderFieldValue(hint) { + return + } + headers.Set(openAICodexRoutingHintHeader, hint) +} + +func deleteOpenAIHeaderEqualFold(headers http.Header, name string) { + if headers == nil { + return + } + name = strings.TrimSpace(name) + for key := range headers { + if strings.EqualFold(strings.TrimSpace(key), name) { + delete(headers, key) + } + } +} + +func setOpenAICodexRoutingHintFromBody(headers http.Header, account *Account, body []byte) { + fields := gjson.GetManyBytes(body, "model", "service_tier") + setOpenAICodexRoutingHint(headers, account, fields[0].String(), fields[1].String()) +} + +// logOpenAIRoutingDiagnostics records only gateway-derived routing state. In +// particular, it deliberately does not include any header values, tokens, or +// credentials because these diagnostics run on authentication-bearing paths. +func logOpenAIRoutingDiagnostics( + ctx context.Context, + account *Account, + transport string, + model string, + serviceTier string, + hintGenerated bool, + wsAffinityDecision string, +) { + if ctx == nil { + ctx = context.Background() + } + accountID := int64(0) + if account != nil { + accountID = account.ID + } + + logger.FromContext(ctx).Debug("openai routing decision", + zap.String("component", "service.openai_routing"), + zap.String("transport", strings.TrimSpace(transport)), + zap.Int64("account_id", accountID), + zap.String("final_model", strings.TrimSpace(model)), + zap.String("final_service_tier", normalizedOpenAIServiceTierValue(serviceTier)), + zap.Bool("routing_hint_generated", hintGenerated), + zap.String("ws_affinity_decision", strings.TrimSpace(wsAffinityDecision)), + ) +} + +func logOpenAIRoutingDiagnosticsFromBody( + ctx context.Context, + account *Account, + transport string, + headers http.Header, + body []byte, + wsAffinityDecision string, +) { + fields := gjson.GetManyBytes(body, "model", "service_tier") + logOpenAIRoutingDiagnostics( + ctx, + account, + transport, + fields[0].String(), + fields[1].String(), + strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "", + wsAffinityDecision, + ) +} diff --git a/backend/internal/service/openai_routing_hint_test.go b/backend/internal/service/openai_routing_hint_test.go new file mode 100644 index 0000000000..5801b15fa4 --- /dev/null +++ b/backend/internal/service/openai_routing_hint_test.go @@ -0,0 +1,369 @@ +package service + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSetOpenAICodexRoutingHintCanonicalizesOfficialServiceTiers(t *testing.T) { + oauthAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + tests := []struct { + name string + model string + serviceTier string + want string + }{ + {name: "fast alias", model: "gpt-5.6", serviceTier: " fast ", want: "model=gpt-5.6;tier=priority"}, + {name: "priority", model: "gpt-5.6", serviceTier: "priority", want: "model=gpt-5.6;tier=priority"}, + {name: "flex", model: "gpt-5.6", serviceTier: "flex", want: "model=gpt-5.6;tier=flex"}, + {name: "explicit default sentinel", model: "gpt-5.6", serviceTier: "default", want: "model=gpt-5.6"}, + {name: "omitted tier", model: "gpt-5.6", want: "model=gpt-5.6"}, + {name: "auto is not expanded without catalog support", model: "gpt-5.6", serviceTier: "auto", want: "model=gpt-5.6"}, + {name: "scale is not expanded without catalog support", model: "gpt-5.6", serviceTier: "scale", want: "model=gpt-5.6"}, + {name: "unknown tier does not expand protocol", model: "gpt-5.6", serviceTier: "turbo", want: "model=gpt-5.6"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + setOpenAICodexRoutingHint(headers, oauthAccount, tt.model, tt.serviceTier) + require.Equal(t, tt.want, headers.Get(openAICodexRoutingHintHeader)) + }) + } + + t.Run("invalid header value is omitted", func(t *testing.T) { + headers := make(http.Header) + setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6\ninvalid", "priority") + require.Empty(t, headers.Get(openAICodexRoutingHintHeader)) + }) + + for _, model := range []string{"gpt-5.6;evil", "gpt=5.6"} { + t.Run("delimiter in model is omitted: "+model, func(t *testing.T) { + headers := make(http.Header) + setOpenAICodexRoutingHint(headers, oauthAccount, model, "priority") + require.Empty(t, headers.Get(openAICodexRoutingHintHeader)) + }) + } + + t.Run("api key strips gateway-owned hint in every key casing", func(t *testing.T) { + headers := make(http.Header) + headers[openAICodexRoutingHintHeader] = []string{"lowercase-spoof"} + headers["X-Codex-Routing-Hint"] = []string{"canonical-spoof"} + setOpenAICodexRoutingHint(headers, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "gpt-5.6", "priority") + for key := range headers { + require.False(t, strings.EqualFold(key, openAICodexRoutingHintHeader)) + } + }) + + t.Run("oauth replaces spoofed lowercase hint", func(t *testing.T) { + headers := make(http.Header) + headers[openAICodexRoutingHintHeader] = []string{"model=spoof;tier=flex"} + setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6", "priority") + require.Equal(t, "model=gpt-5.6;tier=priority", headers.Get(openAICodexRoutingHintHeader)) + require.Len(t, headers, 1) + }) +} + +func TestOpenAIOAuthHTTPBuildersSendRoutingHintFromFinalBody(t *testing.T) { + gin.SetMode(gin.TestMode) + oauthAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "test-account", + }, + } + svc := &OpenAIGatewayService{} + + tests := []struct { + name string + body []byte + want string + }{ + {name: "fast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`), want: "model=gpt-5.6-codex;tier=priority"}, + {name: "flex", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"flex"}`), want: "model=gpt-5.6-codex;tier=flex"}, + {name: "default", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"default"}`), want: "model=gpt-5.6-codex"}, + {name: "omitted", body: []byte(`{"model":"gpt-5.6-codex"}`), want: "model=gpt-5.6-codex"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, passthrough := range []bool{false, true} { + mode := "ordinary" + if passthrough { + mode = "passthrough" + } + t.Run(mode, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(tt.body)) + + var req *http.Request + var err error + if passthrough { + req, err = svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, oauthAccount, tt.body, "test-token") + } else { + req, err = svc.buildUpstreamRequest(context.Background(), c, oauthAccount, tt.body, "test-token", false, "", true) + } + require.NoError(t, err) + require.Equal(t, tt.want, req.Header.Get(openAICodexRoutingHintHeader)) + }) + } + }) + } +} + +func TestOpenAIHTTPPassthroughStripsOnlyOAuthLegacyResponsesBeta(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} + body := []byte(`{"model":"gpt-5.6-codex","service_tier":"priority"}`) + + build := func(t *testing.T, account *Account, betaValues []string, rawLowercaseKey bool) http.Header { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + if rawLowercaseKey { + c.Request.Header["openai-beta"] = append([]string(nil), betaValues...) + } else { + for _, value := range betaValues { + c.Request.Header.Add("OpenAI-Beta", value) + } + } + + req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token") + require.NoError(t, err) + return req.Header + } + + oauth := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "test-account", + }, + } + apiKey := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + }, + } + + t.Run("oauth legacy only is removed including raw lowercase key", func(t *testing.T) { + headers := build(t, oauth, []string{"responses=experimental"}, true) + require.Empty(t, headers.Values("OpenAI-Beta")) + }) + + t.Run("oauth mixed beta preserves independent tokens", func(t *testing.T) { + headers := build(t, oauth, []string{ + "responses=experimental, future_feature=v1", + "another_feature=v2, RESPONSES=EXPERIMENTAL", + }, false) + require.Equal(t, []string{"future_feature=v1", "another_feature=v2"}, headers.Values("OpenAI-Beta")) + }) + + t.Run("api key explicit beta remains caller controlled", func(t *testing.T) { + headers := build(t, apiKey, []string{"responses=experimental, future_feature=v1"}, false) + require.Equal(t, []string{"responses=experimental, future_feature=v1"}, headers.Values("OpenAI-Beta")) + }) +} + +func TestBuildOpenAIWSHeadersSendsOAuthRoutingHintOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + svc := &OpenAIGatewayService{} + decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2} + + build := func(t *testing.T, account *Account, tier string) http.Header { + headers, _, err := svc.buildOpenAIWSHeaders( + context.Background(), + c, + account, + "test-token", + decision, + true, + "", + "", + "", + "gpt-5.6-codex", + tier, + ) + require.NoError(t, err) + return headers + } + + oauthAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "test-account", + }, + } + require.Equal(t, "model=gpt-5.6-codex;tier=priority", build(t, oauthAccount, "fast").Get(openAICodexRoutingHintHeader)) + require.Equal(t, "model=gpt-5.6-codex", build(t, oauthAccount, "default").Get(openAICodexRoutingHintHeader)) + require.Empty(t, build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "priority").Get(openAICodexRoutingHintHeader)) +} + +func TestOpenAIRoutingDiagnosticsUseFinalDerivedValuesOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + logSink, restore := captureStructuredLog(t) + defer restore() + + account := &Account{ + ID: 917, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "chatgpt-account", + }, + } + body := []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`) + svc := &OpenAIGatewayService{} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Authorization", "Bearer caller-secret") + c.Request.Header.Set(openAICodexRoutingHintHeader, "model=caller-secret") + _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "oauth-secret", false, "", true) + require.NoError(t, err) + + decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2} + _, _, err = svc.buildOpenAIWSHeaders( + context.Background(), c, account, "oauth-secret", decision, true, + "", "", "", "gpt-5.6-codex", "fast", + ) + require.NoError(t, err) + + require.True(t, logSink.ContainsMessageAtLevel("openai routing decision", "debug")) + require.True(t, logSink.ContainsFieldValue("account_id", "917")) + require.True(t, logSink.ContainsFieldValue("final_model", "gpt-5.6-codex")) + require.True(t, logSink.ContainsFieldValue("final_service_tier", "priority")) + require.True(t, logSink.ContainsFieldValue("routing_hint_generated", "true")) + require.True(t, logSink.ContainsFieldValue("transport", "http")) + require.True(t, logSink.ContainsFieldValue("transport", string(OpenAIUpstreamTransportResponsesWebsocketV2))) + require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "not_applicable")) + require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "soft_routing_hint")) + require.False(t, logSink.ContainsFieldValue("authorization", "caller-secret")) + require.False(t, logSink.ContainsFieldValue("credentials", "oauth-secret")) + require.False(t, logSink.ContainsFieldValue("routing_hint", "caller-secret")) +} + +func TestOpenAIWSConnPoolPreferredContinuationIgnoresRoutingHintChanges(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 := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + acquire := func(t *testing.T, hint, preferred string, forcePreferred bool) *openAIWSConnLease { + t.Helper() + headers := make(http.Header) + if hint != "" { + headers.Set(openAICodexRoutingHintHeader, hint) + } + lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: headers, + PreferredConnID: preferred, + ForcePreferredConn: forcePreferred, + }) + require.NoError(t, err) + require.NotNil(t, lease) + return lease + } + + standard := acquire(t, "model=gpt-5.6-codex", "", false) + connID := standard.ConnID() + standard.Release() + + priority := acquire(t, "model=gpt-5.6-codex;tier=priority", connID, true) + require.True(t, priority.Reused()) + require.Equal(t, connID, priority.ConnID()) + priority.Release() + + standardAgain := acquire(t, "model=gpt-5.6-codex", connID, true) + require.True(t, standardAgain.Reused()) + require.Equal(t, connID, standardAgain.ConnID()) + standardAgain.Release() + + require.Equal(t, 1, dialer.DialCount(), "routing hint is dial-time advisory, not continuation compatibility") +} + +func TestOpenAIWSConnPoolUsesRoutingHintAsSoftDialAffinity(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 4 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 4 + + pool := newOpenAIWSConnPool(cfg) + dialer := &openAIWSCountingDialer{} + pool.setClientDialerForTest(dialer) + account := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + acquire := func(t *testing.T, hint string) *openAIWSConnLease { + headers := make(http.Header) + headers.Set(openAICodexRoutingHintHeader, hint) + lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: headers, + }) + require.NoError(t, err) + require.NotNil(t, lease) + return lease + } + + priority := acquire(t, "model=gpt-5.6-codex;tier=priority") + priorityConnID := priority.ConnID() + priority.Release() + + priorityAgain := acquire(t, "model=gpt-5.6-codex;tier=priority") + require.True(t, priorityAgain.Reused()) + require.Equal(t, priorityConnID, priorityAgain.ConnID()) + priorityAgain.Release() + + flex := acquire(t, "model=gpt-5.6-codex;tier=flex") + require.False(t, flex.Reused()) + require.NotEqual(t, priorityConnID, flex.ConnID()) + flex.Release() + + otherModel := acquire(t, "model=gpt-5.5-codex;tier=priority") + require.False(t, otherModel.Reused()) + require.NotEqual(t, priorityConnID, otherModel.ConnID()) + otherModel.Release() + + defaultTier := acquire(t, "model=gpt-5.6-codex") + require.False(t, defaultTier.Reused()) + require.NotEqual(t, priorityConnID, defaultTier.ConnID()) + defaultConnID := defaultTier.ConnID() + defaultTier.Release() + + defaultAgain := acquire(t, "model=gpt-5.6-codex") + require.True(t, defaultAgain.Reused()) + require.Equal(t, defaultConnID, defaultAgain.ConnID()) + defaultAgain.Release() + + require.Equal(t, 4, dialer.DialCount()) +} diff --git a/backend/internal/service/openai_silent_refusal.go b/backend/internal/service/openai_silent_refusal.go index 27b71b7571..017c4c1f6d 100644 --- a/backend/internal/service/openai_silent_refusal.go +++ b/backend/internal/service/openai_silent_refusal.go @@ -16,6 +16,7 @@ const ( openAISilentRefusalErrorCode = "openai_silent_refusal" openAISilentRefusalUpstreamMessage = "OpenAI upstream returned an empty completion stream with finish_reason=stop and no usage" openAISilentRefusalClientMessage = "Upstream returned an empty completion without usage; no fallback account was available" + openAIResponsesEmptyCompletedMessage = "OpenAI upstream returned an empty response.completed stream with no output and no usage" ) type openAIChatSilentRefusalDetector struct { @@ -266,6 +267,43 @@ func newOpenAISilentRefusalFailoverError(c *gin.Context, account *Account, upstr } } +// newOpenAIResponsesEmptyCompletedFailoverError marks an empty +// response.completed terminal event as a retryable upstream anomaly. OpenAI +// Responses streams that deliver only response.created + response.completed +// with no output, no usage and no error are treated as silent upstream +// refusals rather than successful empty replies (issue #5009). +func newOpenAIResponsesEmptyCompletedFailoverError(c *gin.Context, account *Account, upstreamRequestID string) *UpstreamFailoverError { + accountID := int64(0) + accountName := "" + platform := PlatformOpenAI + if account != nil { + accountID = account.ID + accountName = account.Name + platform = account.Platform + } + + setOpsUpstreamError(c, http.StatusBadGateway, openAIResponsesEmptyCompletedMessage, "") + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: platform, + AccountID: accountID, + AccountName: accountName, + UpstreamStatusCode: http.StatusBadGateway, + UpstreamRequestID: upstreamRequestID, + Kind: "failover", + Message: openAIResponsesEmptyCompletedMessage, + }) + + headers := http.Header{} + if strings.TrimSpace(upstreamRequestID) != "" { + headers.Set("x-request-id", strings.TrimSpace(upstreamRequestID)) + } + return &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: openAISilentRefusalErrorBody(), + ResponseHeaders: headers, + } +} + func openAISilentRefusalErrorBody() []byte { body, err := json.Marshal(map[string]any{ "error": map[string]any{ diff --git a/backend/internal/service/openai_subscription_test.go b/backend/internal/service/openai_subscription_test.go index 86bb243504..17c6f789df 100644 --- a/backend/internal/service/openai_subscription_test.go +++ b/backend/internal/service/openai_subscription_test.go @@ -125,3 +125,222 @@ func TestShouldApplyChatGPTAccountInfoPlanType(t *testing.T) { require.False(t, shouldApplyChatGPTAccountInfoPlanType("", "")) require.True(t, shouldApplyChatGPTAccountInfoPlanType("", "pro")) } + +func TestChatGPTAccountInfoBelongsToTokenAccount(t *testing.T) { + require.False(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "workspace-b"})) + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "PERSONAL-A"})) + // 任一侧缺 ID 时无法区分,保持既有行为(采用 accounts/check 的值)。 + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{}, &ChatGPTAccountInfo{AccountID: "workspace-b"})) + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{})) +} + +// accounts 的 map key 可能是 "default" 这类别名,account.account_id 才是账号标识。 +func TestFetchChatGPTAccountInfo_ReportsAccountID(t *testing.T) { + futureAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": map[string]any{ + "default": map[string]any{ + "account": map[string]any{ + "account_id": "personal-account-a", + "plan_type": "plus", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": futureAt}, + }, + }, + }) + })) + defer server.Close() + + oldURL := chatGPTAccountsCheckURL + chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27" + t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL }) + + got := fetchChatGPTAccountInfo(context.Background(), newTestPrivacyClientFactory(), "access-token", "", "") + require.NotNil(t, got) + require.Equal(t, "plus", got.PlanType) + require.Equal(t, "personal-account-a", got.AccountID, "应优先取 account.account_id 而不是 map key") +} + +// issue #5459:poid 指向的默认 Personal workspace 与 chatgpt_account_id 是两个不同的 +// 标识时,accounts/check 返回的是 workspace 的 entitlement.expires_at。plan_type 那侧 +// 已被 shouldApplyChatGPTAccountInfoPlanType 挡住(保留 JWT 里的个人套餐),到期时间 +// 这侧原先无条件覆盖,于是显示成「个人 Pro + workspace 到期时间」。 +func TestEnrichTokenInfo_WorkspaceEntitlementDoesNotOverridePersonalSubscription(t *testing.T) { + const ( + personalAccountID = "personal-account-a" + workspaceAccountID = "personal-workspace-b" + personalActiveUntil = "2027-03-01T00:00:00Z" + ) + workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + workspaceAccountID: map[string]any{ + "account": map[string]any{ + "account_id": workspaceAccountID, + "plan_type": "pro", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": workspaceExpiresAt}, + }, + }, + }, + onSubscription: func(accountID string) map[string]any { + subscriptionCalls++ + require.Equal(t, personalAccountID, accountID, + "必须用个人 chatgpt_account_id 查订阅,而不是 poid workspace") + return map[string]any{"plan_type": "pro", "active_until": personalActiveUntil, "will_renew": true} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: personalAccountID, + OrganizationID: workspaceAccountID, + PlanType: "pro", // 来自 id_token 的个人套餐 + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, "pro", tokenInfo.PlanType) + require.Equal(t, personalActiveUntil, tokenInfo.SubscriptionExpiresAt, + "到期时间必须来自个人订阅 active_until,不能是 workspace 的 entitlement.expires_at") + require.NotEqual(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt) + require.Equal(t, 1, subscriptionCalls) +} + +// 单个人账号(poid == chatgpt_account_id)是绝大多数情况,行为必须保持不变: +// 直接用 accounts/check 的 entitlement,不额外打订阅端点。 +func TestEnrichTokenInfo_KeepsEntitlementWhenAccountMatches(t *testing.T) { + const personalAccountID = "personal-account-a" + entitlementExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + personalAccountID: map[string]any{ + "account": map[string]any{ + "account_id": personalAccountID, + "plan_type": "plus", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": entitlementExpiresAt}, + }, + }, + }, + onSubscription: func(string) map[string]any { + subscriptionCalls++ + return map[string]any{} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: personalAccountID, + OrganizationID: personalAccountID, + PlanType: "plus", + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, entitlementExpiresAt, tokenInfo.SubscriptionExpiresAt) + require.Zero(t, subscriptionCalls, "账号一致时不应额外请求订阅端点") +} + +// 反向不变式:套餐本身就取自 accounts/check(JWT 没有 plan_type)时,到期时间必须 +// 跟着取同一条记录,否则会变成「workspace 套餐 + 个人到期时间」的另一种错配。 +func TestEnrichTokenInfo_WorkspacePlanTypeKeepsItsOwnExpiry(t *testing.T) { + const workspaceAccountID = "workspace-b" + workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + workspaceAccountID: map[string]any{ + "account": map[string]any{ + "account_id": workspaceAccountID, + "plan_type": "self_serve_business_usage_based", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": workspaceExpiresAt}, + }, + }, + }, + onSubscription: func(string) map[string]any { + subscriptionCalls++ + return map[string]any{} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: "personal-account-a", + OrganizationID: workspaceAccountID, + // id_token 没带 chatgpt_plan_type + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, "self_serve_business_usage_based", tokenInfo.PlanType) + require.Equal(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt, + "套餐与到期时间必须来自同一条记录") + require.Zero(t, subscriptionCalls) +} + +type chatGPTBackendTestServerConfig struct { + accountsCheck map[string]any + onSubscription func(accountID string) map[string]any +} + +// newChatGPTBackendTestServer 同时接管 accounts/check 与 subscriptions 两个端点, +// 并在 t.Cleanup 里还原包级 URL 变量。 +func newChatGPTBackendTestServer(t *testing.T, cfg chatGPTBackendTestServerConfig) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/backend-api/accounts/check/v4-2023-04-27": + _ = json.NewEncoder(w).Encode(cfg.accountsCheck) + case "/backend-api/subscriptions": + body := map[string]any{} + if cfg.onSubscription != nil { + body = cfg.onSubscription(r.URL.Query().Get("account_id")) + } + _ = json.NewEncoder(w).Encode(body) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + oldAccounts, oldSubscriptions := chatGPTAccountsCheckURL, chatGPTSubscriptionsURL + chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27" + chatGPTSubscriptionsURL = server.URL + "/backend-api/subscriptions" + t.Cleanup(func() { + chatGPTAccountsCheckURL, chatGPTSubscriptionsURL = oldAccounts, oldSubscriptions + }) + return server +} + +// enrichTokenInfo 收尾还会调用 disableOpenAITraining,它的 URL 是常量、指向真实 +// chatgpt.com,测试无法接管。给客户端一个短超时让它快速失败——该调用只写 +// PrivacyMode,不影响本组用例的断言。 +func newTestPrivacyClientFactory() PrivacyClientFactory { + return func(string) (*req.Client, error) { + return req.C().SetTimeout(time.Second), nil + } +} diff --git a/backend/internal/service/openai_upstream_client_error.go b/backend/internal/service/openai_upstream_client_error.go new file mode 100644 index 0000000000..97690e0ab7 --- /dev/null +++ b/backend/internal/service/openai_upstream_client_error.go @@ -0,0 +1,57 @@ +package service + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// openAIUpstreamClientErrorFallbackType 是上游没给 error.type 时的兜底值。 +// 与 handleCompatErrorResponse 对 400 的取值保持一致。 +const openAIUpstreamClientErrorFallbackType = "invalid_request_error" + +// openAIUpstreamClientErrorFallbackMessage 是上游连 message 都没给时的兜底文案。 +// 仍然比 "Upstream request failed" 明确:它说明拒绝来自请求本身,而不是链路故障。 +const openAIUpstreamClientErrorFallbackMessage = "Upstream rejected the request" + +// isOpenAIDeterministicClientError 判断上游状态码是否表示「请求本身非法」。 +// +// 只认 400:同一份请求体换任何账号、重试多少次都会得到同样的结果。 +// - 401/402/403 是网关运营方的凭据/账单问题,继续包成 502,不向客户端暴露上游账号状态。 +// - 404/405 既可能是模型不存在,也可能是上游 base_url 配错,同属运营方问题,保持现状。 +// - 429 已有独立分支映射成 429。 +// - 413 在更上面就按 request-body-too-large 走 failover 了。 +func isOpenAIDeterministicClientError(statusCode int) bool { + return statusCode == http.StatusBadRequest +} + +// writeOpenAIUpstreamClientError 以 OpenAI 错误体形状回写确定性客户端错误。 +// +// 保留上游的 type/code/param:客户端靠 param 定位是哪个字段非法(上游会给出形如 +// input[8].tools[1].tools[2].parameters 的路径),靠 code 判断是否值得重试。归一成 +// {type:"upstream_error", message:"Upstream request failed"} 会把这些信息全部抹掉。 +// +// upstreamMsg 由调用方传入,调用方已做过 sanitizeUpstreamErrorMessage 与 +// redactAgentIdentitySensitiveBody;这里不重复清洗,也不回落读取原始 body 的 +// message,避免绕开那两道脱敏。 +func writeOpenAIUpstreamClientError(c *gin.Context, statusCode int, body []byte, upstreamMsg string) { + errorPayload := gin.H{"type": openAIUpstreamClientErrorFallbackType} + if errType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String()); errType != "" { + errorPayload["type"] = errType + } + if code := strings.TrimSpace(extractUpstreamErrorCode(body)); code != "" { + errorPayload["code"] = code + } + if param := strings.TrimSpace(gjson.GetBytes(body, "error.param").String()); param != "" { + errorPayload["param"] = param + } + message := strings.TrimSpace(upstreamMsg) + if message == "" { + message = openAIUpstreamClientErrorFallbackMessage + } + errorPayload["message"] = message + + c.JSON(statusCode, gin.H{"error": errorPayload}) +} diff --git a/backend/internal/service/openai_upstream_client_error_test.go b/backend/internal/service/openai_upstream_client_error_test.go new file mode 100644 index 0000000000..b9f5b3795a --- /dev/null +++ b/backend/internal/service/openai_upstream_client_error_test.go @@ -0,0 +1,294 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// issue #5479 上游返回的原始错误体:Codex Desktop 的 automation_update 工具定义沉进 +// 会话历史后,OpenAI 每一轮都确定性地拒收。 +const openAIInvalidFunctionParametersBody = `{"error":{` + + `"message":"Invalid schema for function 'automation_update': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"None\"'.",` + + `"type":"invalid_request_error",` + + `"param":"input[8].tools[1].tools[2].parameters",` + + `"code":"invalid_function_parameters"}}` + +func newOpenAIUpstreamErrorTestContext(t *testing.T) (*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) + return c, rec +} + +func newOpenAIUpstreamErrorResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func newOpenAIUpstreamErrorTestAccount() *Account { + return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acct"} +} + +// 主复现:原生 Responses 路径必须回真实的 400 与上游诊断信息,而不是可重试的 502。 +// +// 归一成 502 时下游网关(CCH 等)会把确定性的 Schema 错误当成临时上游故障重试, +// issue #5479 实测 30 个失败请求被放大成 60 次上游调用。 +func TestHandleErrorResponse_Deterministic400IsNotRewrappedAs502(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + _, err := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody), + c, newOpenAIUpstreamErrorTestAccount(), nil, + ) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code, "确定性 400 不得被包成可重试的 502") + + body := rec.Body.String() + require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String()) + require.Equal(t, "invalid_function_parameters", gjson.Get(body, "error.code").String()) + require.Equal(t, "input[8].tools[1].tools[2].parameters", gjson.Get(body, "error.param").String(), + "param 是客户端定位哪个字段非法的唯一线索") + require.Contains(t, gjson.Get(body, "error.message").String(), "Invalid schema for function 'automation_update'") + require.NotContains(t, body, "Upstream request failed") + + // 确定性请求错误不该换号重试——换任何账号都是同样的结果。 + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "400 不得触发 failover") +} + +// 不变式:同一份上游错误体,原生 Responses 与 ChatCompletions/Anthropic 兼容路径 +// 必须给出同样的状态码和同样的 message。这两条路径在同一个 service 上,之前一条对 +// 一条错,正是本次修复的根因;锁死对称性避免将来只改一边。 +func TestHandleErrorResponse_MatchesCompatSiblingForDeterministic400(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + nativeCtx, nativeRec := newOpenAIUpstreamErrorTestContext(t) + _, nativeErr := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody), + nativeCtx, newOpenAIUpstreamErrorTestAccount(), nil, + ) + require.Error(t, nativeErr) + + compatCtx, _ := newOpenAIUpstreamErrorTestContext(t) + var compatStatus int + var compatType, compatMsg string + writeError := func(_ *gin.Context, statusCode int, errType, message string) { + compatStatus, compatType, compatMsg = statusCode, errType, message + } + _, compatErr := svc.handleCompatErrorResponse( + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody), + compatCtx, newOpenAIUpstreamErrorTestAccount(), writeError, + ) + require.Error(t, compatErr) + + require.Equal(t, compatStatus, nativeRec.Code, "两条路径的状态码必须一致") + require.Equal(t, compatType, gjson.Get(nativeRec.Body.String(), "error.type").String(), + "两条路径的 error.type 必须一致") + require.Equal(t, compatMsg, gjson.Get(nativeRec.Body.String(), "error.message").String(), + "两条路径的 message 必须一致") +} + +// 上游只给 message、没有 type/code/param 时,仍要回 400 + 真实 message, +// 缺失字段用 OpenAI 惯例兜底,不得凭空编造 code/param。 +func TestHandleErrorResponse_Deterministic400WithoutUpstreamMetadata(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + _, err := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, `{"error":{"message":"Invalid 'input': expected an array."}}`), + c, newOpenAIUpstreamErrorTestAccount(), nil, + ) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + body := rec.Body.String() + require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String()) + require.Equal(t, "Invalid 'input': expected an array.", gjson.Get(body, "error.message").String()) + require.False(t, gjson.Get(body, "error.code").Exists(), "上游没给 code 就不要编一个") + require.False(t, gjson.Get(body, "error.param").Exists(), "上游没给 param 就不要编一个") +} + +// 上游回非 JSON(反代的 HTML 错误页等)时不得 panic,也不得回空 message。 +func TestHandleErrorResponse_Deterministic400WithNonJSONBody(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + _, err := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, `400 Bad Request`), + c, newOpenAIUpstreamErrorTestAccount(), nil, + ) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + body := rec.Body.String() + require.Equal(t, "invalid_request_error", gjson.Get(body, "error.type").String()) + require.NotEmpty(t, gjson.Get(body, "error.message").String()) +} + +// 作用域守卫:本次只放行 400。其余落到 default 的状态码必须维持原样, +// 避免后续有人顺手把 404/422/5xx 一起改掉。 +func TestHandleErrorResponse_NonDeterministicStatusesKeepGeneric502(t *testing.T) { + cases := []struct { + name string + statusCode int + body string + wantStatus int + wantType string + wantMsg string + }{ + // 404/405 可能是上游 base_url 配错(运营方问题),不当成客户端错误暴露。 + {"not_found", http.StatusNotFound, `{"error":{"message":"Unknown request URL"}}`, + http.StatusBadGateway, "upstream_error", "Upstream request failed"}, + {"unprocessable", http.StatusUnprocessableEntity, `{"error":{"message":"Invalid schema for field messages"}}`, + http.StatusBadGateway, "upstream_error", "Upstream request failed"}, + // 401/402/403 是网关运营方的凭据/账单问题,必须继续对客户端屏蔽上游账号状态。 + {"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"}}`, + http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"}, + // 429 保持独立映射。 + {"rate_limited", http.StatusTooManyRequests, `{"error":{"message":"Rate limit reached"}}`, + http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + _, err := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(tc.statusCode, tc.body), + c, newOpenAIUpstreamErrorTestAccount(), nil, + ) + + require.Error(t, err) + 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()) + }) + } +} + +// 顺序守卫:管理员配置的错误透传规则在更上游命中,新分支不得抢在它前面。 +func TestHandleErrorResponse_PassthroughRuleStillWinsOver400Branch(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + ruleSvc := &ErrorPassthroughService{} + ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{ + newNonFailoverPassthroughRule(http.StatusBadRequest, "automation_update", http.StatusTeapot, "自定义文案"), + }) + BindErrorPassthroughService(c, ruleSvc) + svc := &OpenAIGatewayService{cfg: &config.Config{}} + + _, err := svc.handleErrorResponse( + context.Background(), + newOpenAIUpstreamErrorResponse(http.StatusBadRequest, openAIInvalidFunctionParametersBody), + c, newOpenAIUpstreamErrorTestAccount(), nil, + ) + + require.Error(t, err) + require.Equal(t, http.StatusTeapot, rec.Code, "命中透传规则时必须按规则的状态码回写") + require.Equal(t, "自定义文案", gjson.Get(rec.Body.String(), "error.message").String()) +} + +func TestIsOpenAIDeterministicClientError(t *testing.T) { + require.True(t, isOpenAIDeterministicClientError(http.StatusBadRequest)) + for _, status := range []int{ + http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden, + http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge, + http.StatusUnprocessableEntity, http.StatusTooManyRequests, + http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, + } { + require.False(t, isOpenAIDeterministicClientError(status), "status %d", status) + } +} + +func TestWriteOpenAIUpstreamClientError_PayloadShape(t *testing.T) { + cases := []struct { + name string + body string + upstreamMsg string + wantType string + wantCode string + wantParam string + wantMessage string + }{ + { + name: "full_metadata", + body: openAIInvalidFunctionParametersBody, + upstreamMsg: "Invalid schema for function 'automation_update'", + wantType: "invalid_request_error", + wantCode: "invalid_function_parameters", + wantParam: "input[8].tools[1].tools[2].parameters", + wantMessage: "Invalid schema for function 'automation_update'", + }, + { + name: "upstream_type_preserved", + body: `{"error":{"type":"invalid_prompt","message":"blocked"}}`, + upstreamMsg: "blocked", + wantType: "invalid_prompt", + wantMessage: "blocked", + }, + { + name: "empty_body_falls_back", + body: ``, + upstreamMsg: "", + wantType: "invalid_request_error", + wantMessage: openAIUpstreamClientErrorFallbackMessage, + }, + { + // 调用方传入的 message 已脱敏,必须原样使用,不得回落读取原始 body。 + name: "sanitized_message_wins_over_raw_body", + body: `{"error":{"message":"failed for key=secret123"}}`, + upstreamMsg: "failed for key=***", + wantType: "invalid_request_error", + wantMessage: "failed for key=***", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, rec := newOpenAIUpstreamErrorTestContext(t) + + writeOpenAIUpstreamClientError(c, http.StatusBadRequest, []byte(tc.body), tc.upstreamMsg) + + require.Equal(t, http.StatusBadRequest, rec.Code) + body := rec.Body.String() + require.Equal(t, tc.wantType, gjson.Get(body, "error.type").String()) + require.Equal(t, tc.wantMessage, gjson.Get(body, "error.message").String()) + if tc.wantCode == "" { + require.False(t, gjson.Get(body, "error.code").Exists()) + } else { + require.Equal(t, tc.wantCode, gjson.Get(body, "error.code").String()) + } + if tc.wantParam == "" { + require.False(t, gjson.Get(body, "error.param").Exists()) + } else { + require.Equal(t, tc.wantParam, gjson.Get(body, "error.param").String()) + } + require.NotContains(t, body, "secret123", "原始 body 里的敏感串不得泄漏") + }) + } +} diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 9c0869fbee..51898060ac 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -207,6 +207,10 @@ func (e *OpenAIWSClientCloseError) Reason() string { // OpenAIWSIngressHooks 定义入站 WS 每个 turn 的生命周期回调。 type OpenAIWSIngressHooks struct { + // ClientLifecycleContext is the request context before an ingress lease + // adds its independent cancellation signal. Downstream writes bind to it + // so shutdown and disconnect cancellation remain direct during lease loss. + ClientLifecycleContext context.Context // InitialRequestModel is the client-facing model from the first frame, // before channel or account mapping. Ingress modes preserve it for usage // attribution while MapRequestModel determines the upstream model. diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 3664ae8b20..ce7e8f0386 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -25,6 +25,21 @@ func (s *OpenAIGatewayService) openAIWSIngressInterTurnIdleTimeout() time.Durati return time.Duration(s.cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds) * time.Second } +// newOpenAIWSDownstreamWriteContext binds writes directly to the client +// lifecycle while excluding the separate ingress-lease cancellation signal. +// This lets a lease-loss path finish its current client write before +// ReadOpenAIWSClientMessage sends the retryable close frame. +func newOpenAIWSDownstreamWriteContext(controlCtx context.Context, hooks *OpenAIWSIngressHooks, timeout time.Duration) (context.Context, context.CancelFunc) { + writeParent := controlCtx + if hooks != nil && hooks.ClientLifecycleContext != nil { + writeParent = hooks.ClientLifecycleContext + } + if writeParent == nil { + writeParent = context.Background() + } + return context.WithTimeout(writeParent, timeout) +} + func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ctx context.Context, c *gin.Context, @@ -77,6 +92,11 @@ 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 注释)。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, @@ -364,7 +384,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // the kernel send buffer before any close frame is queued. eventBytes := buildOpenAIFastPolicyBlockedWSEvent(blocked) if eventBytes != nil { - writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) + writeCtx, cancel := newOpenAIWSDownstreamWriteContext(ctx, hooks, s.openAIWSWriteTimeout()) _ = clientConn.Write(writeCtx, coderws.MessageText, eventBytes) cancel() } @@ -391,7 +411,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } writeClientMessage := func(message []byte) error { - writeCtx, cancel := context.WithTimeout(ctx, s.openAIWSWriteTimeout()) + writeCtx, cancel := newOpenAIWSDownstreamWriteContext(ctx, hooks, s.openAIWSWriteTimeout()) defer cancel() return clientConn.Write(writeCtx, coderws.MessageText, message) } @@ -597,7 +617,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } - wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), firstPayload.promptCacheKey) + firstRoutingFields := gjson.GetManyBytes(firstPayload.payloadRaw, "model", "service_tier") + wsHeaders, _, buildHdrErr := s.buildOpenAIWSHeaders( + ctx, + c, + account, + token, + wsDecision, + isCodexCLI, + turnState, + strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), + firstPayload.promptCacheKey, + firstRoutingFields[0].String(), + firstRoutingFields[1].String(), + ) if buildHdrErr != nil { return fmt.Errorf("build ws headers: %w", buildHdrErr) } @@ -765,6 +798,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } 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 { return nil, errors.New("upstream websocket lease is nil") } @@ -832,6 +866,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) + responseModelObserver.ObserveOpenAI(upstreamMessage, eventType) if responseID == "" && eventResponseID != "" { responseID = eventResponseID } @@ -1006,18 +1041,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(payload), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: terminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ServiceTier: extractOpenAIServiceTierFromBody(payload), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: terminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput @@ -1612,16 +1649,30 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if parseErr != nil { return parseErr } + nextRoutingFields := gjson.GetManyBytes(nextPayload.payloadRaw, "model", "service_tier") if nextPayload.promptCacheKey != "" { // ingress 会话在整个客户端 WS 生命周期内复用同一上游连接; // prompt_cache_key 对握手头的更新仅在未来需要重新建连时生效。 - updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), nextPayload.promptCacheKey) + updatedHeaders, _, updHdrErr := s.buildOpenAIWSHeaders( + ctx, + c, + account, + token, + wsDecision, + isCodexCLI, + turnState, + strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)), + nextPayload.promptCacheKey, + nextRoutingFields[0].String(), + nextRoutingFields[1].String(), + ) if updHdrErr != nil { logOpenAIWSModeInfo("ingress_ws_update_headers_failed account_id=%d err=%v", account.ID, updHdrErr) } else { baseAcquireReq.Headers = updatedHeaders } } + setOpenAICodexRoutingHint(baseAcquireReq.Headers, account, nextRoutingFields[0].String(), nextRoutingFields[1].String()) if nextPayload.previousResponseID != "" { expectedPrev := strings.TrimSpace(lastTurnResponseID) chainedFromLast := expectedPrev != "" && nextPayload.previousResponseID == expectedPrev 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 c90a1ebc0c..6715a01ae4 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -18,6 +18,80 @@ import ( "github.com/tidwall/gjson" ) +type openAIWSLeaseLossAfterReadConn struct { + *openAIWSCaptureConn + cancel context.CancelCauseFunc + once sync.Once +} + +func (c *openAIWSLeaseLossAfterReadConn) ReadMessage(ctx context.Context) ([]byte, error) { + message, err := c.openAIWSCaptureConn.ReadMessage(ctx) + if err == nil { + c.once.Do(func() { + c.cancel(ErrOpenAIWSIngressLeaseLost) + }) + } + return message, err +} + +type openAIWSSingleConnDialer struct { + conn openAIWSClientConn +} + +func (d *openAIWSSingleConnDialer) Dial( + ctx context.Context, + wsURL string, + headers http.Header, + proxyURL string, +) (openAIWSClientConn, int, http.Header, error) { + return d.conn, 0, nil, nil +} + +func TestOpenAIWSDownstreamWriteContext_CancellationOwnership(t *testing.T) { + t.Run("pre-canceled ordinary context is canceled before return", func(t *testing.T) { + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + cancelControl(context.Canceled) + + writeCtx, cancelWrite := newOpenAIWSDownstreamWriteContext(controlCtx, nil, time.Second) + defer cancelWrite() + require.ErrorIs(t, writeCtx.Err(), context.Canceled) + }) + + t.Run("lease loss keeps current write alive", func(t *testing.T) { + lifecycleCtx, cancelLifecycle := context.WithCancelCause(context.Background()) + controlCtx, cancelControl := context.WithCancelCause(lifecycleCtx) + hooks := &OpenAIWSIngressHooks{ClientLifecycleContext: lifecycleCtx} + writeCtx, cancelWrite := newOpenAIWSDownstreamWriteContext(controlCtx, hooks, time.Second) + defer cancelWrite() + + cancelControl(ErrOpenAIWSIngressLeaseLost) + select { + case <-writeCtx.Done(): + t.Fatalf("lease loss unexpectedly canceled downstream write: %v", writeCtx.Err()) + case <-time.After(20 * time.Millisecond): + } + + clientDisconnected := errors.New("client disconnected") + cancelLifecycle(clientDisconnected) + <-writeCtx.Done() + require.ErrorIs(t, context.Cause(writeCtx), clientDisconnected) + }) + + t.Run("ordinary cancellation is direct and preserves cause", func(t *testing.T) { + lifecycleCtx, cancelLifecycle := context.WithCancelCause(context.Background()) + controlCtx, cancelControl := context.WithCancelCause(lifecycleCtx) + defer cancelControl(context.Canceled) + hooks := &OpenAIWSIngressHooks{ClientLifecycleContext: lifecycleCtx} + writeCtx, cancelWrite := newOpenAIWSDownstreamWriteContext(controlCtx, hooks, time.Second) + defer cancelWrite() + + serverShutdown := errors.New("server shutdown") + cancelLifecycle(serverShutdown) + require.ErrorIs(t, writeCtx.Err(), context.Canceled) + require.ErrorIs(t, context.Cause(writeCtx), serverShutdown) + }) +} + func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossTurns(t *testing.T) { gin.SetMode(gin.TestMode) @@ -169,6 +243,137 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT require.Len(t, captureConn.writes, 2, "应向同一上游连接发送两轮 response.create") } +func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_LeaseLossSendsRetryClose(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.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 = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + lifecycleCtx, cancelLifecycle := context.WithCancelCause(context.Background()) + defer cancelLifecycle(context.Canceled) + controlCtx, cancelControl := context.WithCancelCause(lifecycleCtx) + upstreamConn := &openAIWSLeaseLossAfterReadConn{ + openAIWSCaptureConn: &openAIWSCaptureConn{events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_lease_loss","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), + }}, + cancel: cancelControl, + } + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSSingleConnDialer{conn: upstreamConn}) + defer pool.Close() + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 118, + Name: "openai-ingress-lease-loss", + 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}, + } + + serverErrCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + serverErrCh <- err + return + } + defer func() { + _ = conn.CloseNow() + }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := r.Clone(controlCtx) + req.Header = req.Header.Clone() + req.Header.Set("User-Agent", "unit-test-agent/1.0") + ginCtx.Request = req + + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + msgType, firstMessage, readErr := conn.Read(readCtx) + cancelRead() + if readErr != nil { + serverErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + serverErrCh <- errors.New("unsupported websocket client message type") + return + } + + serverErrCh <- svc.ProxyResponsesWebSocketFromClient( + controlCtx, + ginCtx, + conn, + account, + "sk-test", + firstMessage, + &OpenAIWSIngressHooks{ClientLifecycleContext: lifecycleCtx}, + ) + })) + 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() + }() + + 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}`)) + cancelWrite() + require.NoError(t, err) + + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + msgType, event, err := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, err) + require.Equal(t, coderws.MessageText, msgType) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + closeReadCtx, cancelCloseRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, err = clientConn.Read(closeReadCtx) + cancelCloseRead() + var closeErr coderws.CloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusTryAgainLater, closeErr.Code) + require.Equal(t, "websocket ingress capacity lease lost; please reconnect", closeErr.Reason) + + select { + case serverErr := <-serverErrCh: + var clientCloseErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &clientCloseErr) + require.Equal(t, coderws.StatusTryAgainLater, clientCloseErr.StatusCode()) + require.ErrorIs(t, serverErr, ErrOpenAIWSIngressLeaseLost) + case <-time.After(3 * time.Second): + t.Fatal("ingress lease-loss reader did not exit") + } +} + func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_IdleTimeoutReleasesStoreDisabledSession(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 7753ea9598..a13248e393 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -551,13 +551,22 @@ func TestNormalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(t *testing.T) t.Parallel() normalized, err := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID( - []byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","metadata":{"b":2,"a":1}}`), + []byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","client_metadata":{"request_start_ms":"1"},"stream_options":{"include_usage":true},"generate":false,"metadata":{"b":2,"a":1}}`), ) require.NoError(t, err) require.False(t, gjson.GetBytes(normalized, "input").Exists()) require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(normalized, "client_metadata").Exists()) + require.False(t, gjson.GetBytes(normalized, "stream_options").Exists()) + require.False(t, gjson.GetBytes(normalized, "generate").Exists()) require.Equal(t, float64(1), gjson.GetBytes(normalized, "metadata.a").Float()) + normalized, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID( + []byte(`{"model":"gpt-5.1","generate":true}`), + ) + require.NoError(t, err) + require.True(t, gjson.GetBytes(normalized, "generate").Bool()) + _, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(nil) require.Error(t, err) @@ -662,6 +671,36 @@ func TestShouldKeepIngressPreviousResponseID(t *testing.T) { require.Equal(t, "strict_incremental_ok", reason) }) + t.Run("codex_prewarm_to_business_keep", func(t *testing.T) { + prewarmPayload := []byte(`{ + "type":"response.create", + "model":"gpt-5.1", + "store":false, + "generate":false, + "client_metadata":{"x-codex-ws-stream-request-start-ms":"100"}, + "stream_options":{"include_usage":true}, + "input":[{"type":"input_text","text":"hello"}] + }`) + businessPayload := []byte(`{ + "type":"response.create", + "model":"gpt-5.1", + "store":false, + "client_metadata":{"x-codex-ws-stream-request-start-ms":"200"}, + "previous_response_id":"resp_prewarm", + "input":[{"type":"input_text","text":"hello"}] + }`) + + keep, reason, err := shouldKeepIngressPreviousResponseID( + prewarmPayload, + businessPayload, + "resp_prewarm", + false, + ) + require.NoError(t, err) + require.True(t, keep) + require.Equal(t, "strict_incremental_ok", reason) + }) + t.Run("missing_previous_response_id", func(t *testing.T) { payload := []byte(`{"type":"response.create","model":"gpt-5.1","input":[]}`) keep, reason, err := shouldKeepIngressPreviousResponseID(previousPayload, payload, "resp_turn_1", false) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index b598813f18..c53d9e8241 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -75,6 +75,8 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( turnState string, turnMetadata string, promptCacheKey string, + routingModel string, + routingServiceTier string, ) (http.Header, openAIWSSessionHeaderResolution, error) { headers := make(http.Header) if account == nil || !account.IsOpenAIAgentIdentity() { @@ -148,16 +150,25 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { headers.Set("user-agent", codexCLIUserAgent) } - // 终态收口:originator 必须与最终 user-agent 首段配套且为官方身份,非官方 UA 整体回退为 - // 默认 Codex CLI 身份(承接原「非 Codex UA 兜底」,并修复其把 codex-tui 等官方 UA 改写为 - // codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。 + // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 + // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 if account != nil && account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(headers) + enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account)) } // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 // 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。 account.ApplyHeaderOverrides(headers) + setOpenAICodexRoutingHint(headers, account, routingModel, routingServiceTier) + logOpenAIRoutingDiagnostics( + ctx, + account, + string(decision.Transport), + routingModel, + routingServiceTier, + strings.TrimSpace(headers.Get(openAICodexRoutingHintHeader)) != "", + "soft_routing_hint", + ) return headers, sessionResolution, nil } @@ -450,6 +461,17 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( } delete(decoded, "input") delete(decoded, "previous_response_id") + // Codex changes transport-only metadata for every response.create. These fields + // do not alter the context referenced by previous_response_id and are excluded + // from Codex's own websocket reuse comparison. + delete(decoded, "client_metadata") + delete(decoded, "stream_options") + // Official Codex prewarms a connection with generate=false, then omits the + // field on the business request that continues from the prewarm response. + // Only normalize false so a meaningful generate=true change remains visible. + if generate, ok := decoded["generate"].(bool); ok && !generate { + delete(decoded, "generate") + } return json.Marshal(decoded) } diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index c7cbbc0b2f..d8270dfe40 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -413,6 +414,8 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test "", "", "", + "", + "", ) require.NoError(t, err) @@ -757,24 +760,25 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) - // 上游要求 originator 与最终 user-agent 首段配套(issue #3901): - // originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。 + // WS 握手头与 HTTP 出站共用身份收口:同样强制统一为网关规范身份, + // 客户端自报的 originator / user-agent 不参与构造(issue #3901 的配对不变式自然满足)。 tests := []struct { - name string - userAgent string - originator string - wantOriginator string - wantUA string + name string + userAgent string + originator string }{ - {name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"}, + {name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"}, { - name: "mismatched originator repaired from ua", - userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", - originator: "codex_cli_rs", - wantOriginator: "codex-tui", - wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + name: "mismatched originator", + userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)", + originator: "codex_cli_rs", }, - {name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent}, + { + name: "tui identity", + userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", + originator: "codex-tui", + }, + {name: "official originator without ua", originator: "codex_vscode"}, } for _, tt := range tests { @@ -838,12 +842,82 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) - require.Equal(t, tt.wantOriginator, captureDialer.lastHeaders.Get("originator")) - require.Equal(t, tt.wantUA, captureDialer.lastHeaders.Get("user-agent")) + require.Equal(t, openai.CodexDefaultOriginator, captureDialer.lastHeaders.Get("originator")) + require.Equal(t, codexCLIUserAgent, captureDialer.lastHeaders.Get("user-agent")) + require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version")) }) } } +// 账号级自定义 UA 是管理员的显式配置,WS 握手与 HTTP 出站必须一视同仁地生效—— +// 否则同一个账号在两种传输上以不同身份出站。指纹保留、版本段重建、originator 配套。 +func TestOpenAIGatewayService_Forward_WSv2_OAuthHonorsAccountUserAgent(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", "luna/1.0.0") + + 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.AllowStoreRecovery = false + 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_oauth_account_ua","model":"gpt-5.1","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: 130, + Name: "openai-oauth-custom-ua", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token-1", + // 填写于某个历史版本的账号级 UA:指纹要保留,版本段不能被逐字沿用。 + "user_agent": "codex-tui/0.125.0 (Mac OS X 15.1.0; arm64) iTerm.app", + }, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + body := []byte(`{"model":"gpt-5.1","stream":false,"input":[{"type":"input_text","text":"hello"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "codex-tui", captureDialer.lastHeaders.Get("originator")) + require.Equal(t, + "codex-tui/"+codexCLIVersion+" (Mac OS X 15.1.0; arm64) iTerm.app", + captureDialer.lastHeaders.Get("user-agent"), + ) + require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version")) +} + func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheKey(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 91b9ef5cb1..6c6d7c9cf3 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -384,6 +384,9 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( excludedIDs map[int64]struct{}, requireCompact bool, ) (*AccountSelectionResult, error) { + // 分组利润控制:公共入口装门,保证不经 selectAccountWithScheduler + // 的调用方也无法绕过利润准入(scheduler 内部路径已在唯一调度入口装门)。 + ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact) } @@ -412,16 +415,16 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( responseID, store.BindResponseAccount(ctx, derefGroupID(groupID), responseID, accountID, s.openAIWSResponseStickyTTL()), ) - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, Acquired: true, ReleaseFunc: result.ReleaseFunc, - }, nil + }), nil } cfg := s.schedulingConfig() if s.concurrencyService != nil { - return &AccountSelectionResult{ + return attachSelectionProfitGate(ctx, &AccountSelectionResult{ Account: account, WaitPlan: &AccountWaitPlan{ AccountID: accountID, @@ -429,7 +432,7 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( Timeout: cfg.StickySessionWaitTimeout, MaxWaiting: cfg.StickySessionMaxWaiting, }, - }, nil + }), nil } return nil, nil } @@ -509,6 +512,12 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { return 0, nil, "", nil } + // 分组利润控制:与 quota auto-pause 同语义——利润不合格是暂时 + // 状态(上游倍率/高峰随时间变化),只跳过本次复用、落回普通调度,不删除 + // 绑定(倍率恢复后可继续按 previous_response_id 粘连)。 + if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { + return 0, nil, "", nil + } if s.schedulerSnapshot != nil && s.accountRepo != nil { latest, latestErr := s.accountRepo.GetByID(ctx, account.ID) if latestErr != nil || latest == nil { @@ -532,6 +541,10 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { return 0, nil, "", nil } + // 利润门对最新账号状态复检一次,语义同上:跳过复用、不删绑定。 + if vetoed, _ := openAIProfitControlVetoReason(ctx, latest); vetoed { + return 0, nil, "", nil + } if s.isOpenAIAccountRequestRuntimeBlocked(latest, requestedModel) { _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) return 0, nil, "", nil diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 62aee29c20..e0dbd58bd4 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -36,6 +36,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if s == nil || account == nil { return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) } + responseModelObserver := &upstreamResponseModelObserver{} wsURL, err := s.buildOpenAIResponsesWSURL(account) if err != nil { @@ -136,7 +137,19 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( storeDisabledConnMode := s.openAIWSStoreDisabledConnMode() forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason) forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == "" - wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders( + ctx, + c, + account, + token, + decision, + isCodexCLI, + turnState, + turnMetadata, + promptCacheKey, + openAIWSPayloadString(payload, "model"), + openAIWSPayloadString(payload, "service_tier"), + ) if buildHdrErr != nil { return nil, fmt.Errorf("build ws headers: %w", buildHdrErr) } @@ -512,6 +525,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if eventType == "" { continue } + responseModelObserver.ObserveOpenAI(message, eventType) eventCount++ if firstEventType == "" { firstEventType = eventType @@ -748,20 +762,22 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ) return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ImageCount: imageCounter.Count(), + ImageOutputSizes: imageCounter.Sizes(), + ServiceTier: extractOpenAIServiceTier(reqBody), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index b2c7c67f64..ac6a24e78a 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -180,6 +180,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if writeClientMessage == nil { return nil, errors.New("client websocket writer is nil") } + responseModelObserver := &upstreamResponseModelObserver{} body, err := prepareOpenAIWSHTTPBridgeBody(payload) if err != nil { @@ -207,7 +208,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( 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) + upstreamReq, err = buildGrokResponsesRequest(upstreamCtx, c, account, body, token, grokCacheIdentity, s.cfg, s.settingService) } else { upstreamReq, err = s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token) } @@ -299,18 +300,20 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( resultWithUsage := func() *OpenAIForwardResult { imageCount := imageCounter.Count() result := &OpenAIForwardResult{ - RequestID: responseID, - Usage: usage, - Model: originalModel, - UpstreamModel: mappedModel, - ServiceTier: extractOpenAIServiceTierFromBody(body), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: cloneHeader(resp.Header), - Duration: time.Since(turnStart), - FirstTokenMs: firstTokenMs, + RequestID: responseID, + Usage: usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + ServiceTier: extractOpenAIServiceTierFromBody(body), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: cloneHeader(resp.Header), + Duration: time.Since(turnStart), + FirstTokenMs: firstTokenMs, } if replayInput := replayCollector.Items(); len(replayInput) > 0 { result.wsReplayInput = replayInput @@ -355,6 +358,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( upstreamMessage = normalized } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) + responseModelObserver.ObserveOpenAI(upstreamMessage, eventType) if responseID == "" && eventResponseID != "" { responseID = eventResponseID } @@ -421,8 +425,18 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( upstreamEventErr = errors.New(errMessage) } + // 客户端写出副本改写容量降载码:Codex 对 error/response.failed 中的 + // server_is_overloaded / slow_down 判致命并终止会话,改写后走客户端内置 + // 重试。账号状态与终止事件判定(下方 handleOpenAIWSTerminalTransientFailure) + // 仍使用未改写的 upstreamMessage。 + clientMessage := upstreamMessage + if eventType == "error" || eventType == "response.failed" { + if rewritten, changed := sanitizeOpenAICapacityShedErrorCodeForClient(clientMessage); changed { + clientMessage = rewritten + } + } if !clientDisconnected { - if err := writeClientMessage(upstreamMessage); err != nil { + if err := writeClientMessage(clientMessage); err != nil { if isOpenAIWSClientDisconnectError(err) { clientDisconnected = true closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 802146cd3f..1e352f012e 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -220,6 +220,69 @@ func TestProxyOpenAIWSHTTPBridgeTurnSSEErrorFailoverSafety(t *testing.T) { } } +// 桥接转发 error / response.failed 给 WS 客户端前必须把容量降载码改写为可重试 +// 的 server_error:Codex 对 server_is_overloaded/slow_down 判致命并终止会话。 +// 账号状态判定使用改写前的原始事件,不受影响。 +func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + turn int + body string + wantErr bool + }{ + { + name: "turn2_error_frame", + turn: 2, + body: "data: {\"type\":\"error\",\"error\":{\"type\":\"service_unavailable_error\",\"code\":\"server_is_overloaded\",\"message\":\"Our servers are currently overloaded. Please try again later.\"}}\n\n", + wantErr: true, + }, + { + // response.failed 不走 error 事件分支:即便 turn 1 也会被当终止事件 + // 原样转发(不 failover),因此改写必须在这里同样生效。 + name: "turn1_bare_response_failed", + turn: 1, + 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", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 11, 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 + + _, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", tt.turn, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Len(t, writes, 1) + require.Contains(t, string(writes[0]), `"code":"server_error"`) + require.NotContains(t, string(writes[0]), "server_is_overloaded") + require.Contains(t, string(writes[0]), "Our servers are currently overloaded") + }) + } +} + func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -671,7 +734,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMa require.Len(t, upstream.bodies, 3) require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) + 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.3", gjson.GetBytes(upstream.bodies[1], "model").String()) diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go new file mode 100644 index 0000000000..bb6e5ebde6 --- /dev/null +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// startPassthroughHookRecordingServer 与 startPassthroughLifecycleServer 相同, +// 但把一组会记录调用的 hooks 传给 ingress,用于观察透传路径的 turn 回调。 +func startPassthroughHookRecordingServer( + t *testing.T, + controlCtx context.Context, + svc *OpenAIGatewayService, + account *Account, + hooks *OpenAIWSIngressHooks, +) (*httptest.Server, <-chan error) { + t.Helper() + serverErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + serverErr <- err + return + } + defer func() { _ = conn.CloseNow() }() + + msgType, firstMessage, err := ReadOpenAIWSClientMessage( + controlCtx, + conn, + 3*time.Second, + coderws.StatusPolicyViolation, + "missing first response.create message", + ) + if err != nil { + serverErr <- err + return + } + if msgType != coderws.MessageText { + serverErr <- errors.New("first message was not text") + return + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + req := r.Clone(controlCtx) + req.Header = req.Header.Clone() + ginCtx.Request = req + serverErr <- svc.ProxyResponsesWebSocketFromClient(controlCtx, ginCtx, conn, account, "sk-test", firstMessage, hooks) + })) + return server, serverErr +} + +// TestPassthroughIngressNeverCallsBeforeTurn 钉死 ws_v2 透传 ingress 与 handler +// 侧 turn 定价的耦合:透传 relay 只回调 AfterTurn,没有任何 turn 起始回调, +// 因此 hooks.BeforeTurn 永远不会触发。 +// +// handler 依赖这一点:openAIWSTurnPricing 零值起步,透传连接的每个 turn 都拿 +// 不到冻结的 pricingAt,RecordUsage 回退到记录时刻——与引入分组利润控制前的 +// 基线一致。若把 turn 定价初始化成建连时刻,透传连接的所有 turn 就会被钉死在 +// 建连时的高峰因子,客户端峰前建连保活即可全程按谷价结算。 +// +// 若本断言因为透传补齐了 turn 起始回调而失败:这是好事,请同步复核 +// openAIWSTurnPricing 的零值语义与透传路径的 turn 级利润复核。 +func TestPassthroughIngressNeverCallsBeforeTurn(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_pricing","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + + var hooksMu sync.Mutex + beforeTurnCalls := 0 + afterTurnCalls := 0 + hooks := &OpenAIWSIngressHooks{ + BeforeTurn: func(int) error { + hooksMu.Lock() + beforeTurnCalls++ + hooksMu.Unlock() + return nil + }, + AfterTurn: func(int, *OpenAIForwardResult, error) { + hooksMu.Lock() + afterTurnCalls++ + hooksMu.Unlock() + }, + } + + server, serverErr := startPassthroughHookRecordingServer( + t, + controlCtx, + newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), + passthroughLifecycleAccount(), + hooks, + ) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + + event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + // 等待连接自然结束(inter-turn idle 超时),确保 AfterTurn 已提交。 + _, _ = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("passthrough ingress did not exit") + } + + hooksMu.Lock() + gotBefore, gotAfter := beforeTurnCalls, afterTurnCalls + hooksMu.Unlock() + + require.Zero(t, gotBefore, "透传 ingress 没有 turn 起始回调,BeforeTurn 不应被调用") + require.Positive(t, gotAfter, "透传 ingress 仍应回调 AfterTurn 提交用量") +} diff --git a/backend/internal/service/openai_ws_pool.go b/backend/internal/service/openai_ws_pool.go index 8579cc58d6..048031d640 100644 --- a/backend/internal/service/openai_ws_pool.go +++ b/backend/internal/service/openai_ws_pool.go @@ -76,6 +76,10 @@ type openAIWSAcquireRequest struct { ForcePreferredConn bool } +type openAIWSHandshakeCompatibilityKey struct { + betaFeatures string +} + type openAIWSConnLease struct { pool *openAIWSConnPool accountID int64 @@ -240,8 +244,9 @@ type openAIWSConn struct { id string ws openAIWSClientConn - handshakeHeaders http.Header - betaFeatures string + handshakeHeaders http.Header + handshakeCompatibility openAIWSHandshakeCompatibilityKey + routingAffinity string leaseCh chan struct{} closedCh chan struct{} @@ -305,6 +310,14 @@ func (c *openAIWSConn) acquire(ctx context.Context) error { case <-c.closedCh: return errOpenAIWSConnClosed case <-c.leaseCh: + // A cancellation and a lease delivery can become ready together. Once + // the semaphore token has been consumed, check the context again and + // return it before reporting cancellation so a canceled waiter cannot + // strand a pooled connection. + if err := ctx.Err(); err != nil { + c.release() + return err + } select { case <-c.closedCh: c.release() @@ -525,8 +538,12 @@ func (c *openAIWSConn) handshakeHeader(name string) string { return strings.TrimSpace(c.handshakeHeaders.Get(strings.TrimSpace(name))) } -func (c *openAIWSConn) matchesBetaFeatures(betaFeatures string) bool { - return c != nil && c.betaFeatures == betaFeatures +func (c *openAIWSConn) matchesHandshakeCompatibility(compatibility openAIWSHandshakeCompatibilityKey) bool { + return c != nil && c.handshakeCompatibility == compatibility +} + +func (c *openAIWSConn) matchesRoutingAffinity(routingAffinity string) bool { + return c != nil && c.routingAffinity == routingAffinity } func (c *openAIWSConn) isPrewarmed() bool { @@ -838,7 +855,8 @@ func (p *openAIWSConnPool) acquire(ctx context.Context, req openAIWSAcquireReque retryAcquire: accountID := req.Account.ID - betaFeatures := normalizeOpenAIWSBetaFeatures(req.Headers) + compatibility := normalizeOpenAIWSHandshakeCompatibility(req.Headers) + routingAffinity := normalizeOpenAIWSRoutingAffinity(req.Headers) effectiveMaxConns := p.effectiveMaxConnsByAccount(req.Account) if effectiveMaxConns <= 0 { return nil, errOpenAIWSConnQueueFull @@ -846,7 +864,7 @@ retryAcquire: var evicted []*openAIWSConn ap := p.getOrCreateAccountPool(accountID) ap.mu.Lock() - ap.lastAcquire = cloneOpenAIWSAcquireRequestPtr(&req) + acquireGeneration := ap.generation now := time.Now() if ap.lastCleanupAt.IsZero() || now.Sub(ap.lastCleanupAt) >= openAIWSAcquireCleanupInterval { evicted = p.cleanupAccountLocked(ap, now, effectiveMaxConns) @@ -866,7 +884,7 @@ retryAcquire: return nil, errOpenAIWSPreferredConnUnavailable } preferredConn, ok := ap.conns[preferredConnID] - if !ok || !preferredConn.matchesBetaFeatures(betaFeatures) { + if !ok || !preferredConn.matchesHandshakeCompatibility(compatibility) { p.recordConnPickDuration(time.Since(pickStartedAt)) ap.mu.Unlock() closeOpenAIWSConns(evicted) @@ -895,6 +913,7 @@ retryAcquire: reused: true, } p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) p.ensureTargetIdleAsync(accountID) return lease, nil } @@ -942,12 +961,13 @@ retryAcquire: reused: true, } p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) p.ensureTargetIdleAsync(accountID) return lease, nil } if preferredConnID != "" { - if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) && conn.tryAcquire() { + if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesHandshakeCompatibility(compatibility) && conn.tryAcquire() { connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) ap.mu.Unlock() @@ -964,12 +984,16 @@ retryAcquire: } 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 } } - best := p.pickLeastBusyConnLocked(ap, "", betaFeatures) + // A routing hint is advisory at WebSocket dial time. Prefer a pooled + // connection whose handshake used the same hint, but do not make that + // preference a continuation compatibility requirement. + best := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity) if best != nil && best.tryAcquire() { connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) @@ -987,11 +1011,12 @@ retryAcquire: } lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: best, connPick: connPick, reused: true} p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) p.ensureTargetIdleAsync(accountID) return lease, nil } for _, conn := range ap.conns { - if conn == nil || conn == best || !conn.matchesBetaFeatures(betaFeatures) { + if conn == nil || conn == best || !conn.matchesHandshakeCompatibility(compatibility) || !conn.matchesRoutingAffinity(routingAffinity) { continue } if conn.tryAcquire() { @@ -1011,6 +1036,7 @@ retryAcquire: } 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 } @@ -1018,12 +1044,18 @@ retryAcquire: } if !req.ForceNewConn && len(ap.conns)+ap.creating >= effectiveMaxConns { - compatible := p.pickLeastBusyConnLocked(ap, "", betaFeatures) - if idle := p.pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap, betaFeatures); idle != nil { + affine := p.pickLeastBusyConnWithRoutingAffinityLocked(ap, compatibility, routingAffinity) + if idle := p.pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked(ap, compatibility, routingAffinity); idle != nil { delete(ap.conns, idle.id) evicted = append(evicted, idle) p.metrics.scaleDownTotal.Add(1) - } else if compatible == nil { + } else if affine == nil { + compatible := p.pickLeastBusyConnLocked(ap, "", compatibility) + if compatible != nil { + // Capacity is full and every compatible connection is busy. The + // hint remains soft here: queue on a compatible connection below. + goto acquireAtCapacity + } hasConnection := false for _, conn := range ap.conns { if conn != nil { @@ -1068,6 +1100,17 @@ retryAcquire: ap = p.getOrCreateAccountPool(accountID) ap.mu.Lock() ap.creating-- + if ap.generation != acquireGeneration { + ap.signalChangedLocked() + ap.mu.Unlock() + if conn != nil { + conn.close() + } + if retry < 1 { + return p.acquire(ctx, req, retry+1) + } + return nil, errOpenAIWSConnClosed + } if dialErr != nil { ap.prewarmFails++ ap.prewarmFailAt = time.Now() @@ -1075,20 +1118,26 @@ retryAcquire: ap.mu.Unlock() return nil, dialErr } + // Claim the freshly dialed connection before publishing it. Otherwise a + // topology waiter awakened below can take the free semaphore first and + // make the caller that paid for the dial queue behind it. + if !conn.tryAcquire() { + ap.signalChangedLocked() + ap.mu.Unlock() + conn.close() + return nil, errOpenAIWSConnClosed + } ap.conns[conn.id] = conn ap.prewarmFails = 0 ap.prewarmFailAt = time.Time{} + // Wake acquires that observed creating>0 with no compatible connection. + // Without this signal they can remain asleep until the new lease is + // released, even though the pool topology already changed. + ap.signalChangedLocked() ap.mu.Unlock() p.metrics.acquireCreateTotal.Add(1) - - if !conn.tryAcquire() { - if err := conn.acquire(ctx); err != nil { - conn.close() - p.evictConn(accountID, conn.id) - return nil, err - } - } lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: conn, connPick: connPick} + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) p.ensureTargetIdleAsync(accountID) return lease, nil } @@ -1100,7 +1149,8 @@ retryAcquire: return nil, errOpenAIWSConnQueueFull } - target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID, betaFeatures) +acquireAtCapacity: + target := p.pickLeastBusyConnLocked(ap, req.PreferredConnID, compatibility) connPick := time.Since(pickStartedAt) p.recordConnPickDuration(connPick) if target == nil { @@ -1142,6 +1192,7 @@ retryAcquire: p.metrics.acquireQueueWaitMs.Add(queueWait.Milliseconds()) lease := &openAIWSConnLease{pool: p, accountID: accountID, conn: target, queueWait: queueWait, connPick: connPick, reused: true} p.metrics.acquireReuseTotal.Add(1) + p.recordLastSuccessfulAcquire(accountID, acquireGeneration, req) p.ensureTargetIdleAsync(accountID) return lease, nil } @@ -1157,6 +1208,23 @@ func (p *openAIWSConnPool) recordConnPickDuration(duration time.Duration) { p.metrics.connPickMs.Add(duration.Milliseconds()) } +func (p *openAIWSConnPool) recordLastSuccessfulAcquire(accountID int64, generation uint64, req openAIWSAcquireRequest) { + if p == nil || accountID <= 0 { + return + } + ap, ok := p.getAccountPool(accountID) + if !ok || ap == nil { + return + } + ap.mu.Lock() + if ap.generation != generation { + ap.mu.Unlock() + return + } + ap.lastAcquire = cloneOpenAIWSAcquireRequestPtr(&req) + ap.mu.Unlock() +} + func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil @@ -1173,13 +1241,19 @@ func (p *openAIWSConnPool) pickOldestIdleConnLocked(ap *openAIWSAccountPool) *op return oldest } -func (p *openAIWSConnPool) pickOldestIdleConnWithDifferentBetaFeaturesLocked(ap *openAIWSAccountPool, betaFeatures string) *openAIWSConn { +func (p *openAIWSConnPool) pickOldestIdleConnWithoutHandshakeCompatibilityOrRoutingAffinityLocked( + ap *openAIWSAccountPool, + compatibility openAIWSHandshakeCompatibilityKey, + routingAffinity string, +) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil } var oldest *openAIWSConn for _, conn := range ap.conns { - if conn == nil || conn.matchesBetaFeatures(betaFeatures) || conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) { + if conn == nil || + (conn.matchesHandshakeCompatibility(compatibility) && conn.matchesRoutingAffinity(routingAffinity)) || + conn.isLeased() || conn.waiters.Load() > 0 || p.isConnPinnedLocked(ap, conn.id) { continue } if oldest == nil || conn.lastUsedAt().Before(oldest.lastUsedAt()) { @@ -1330,13 +1404,17 @@ func (p *openAIWSConnPool) cleanupAccountLocked(ap *openAIWSAccountPool, now tim return evicted } -func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, preferredConnID, betaFeatures string) *openAIWSConn { +func (p *openAIWSConnPool) pickLeastBusyConnLocked( + ap *openAIWSAccountPool, + preferredConnID string, + compatibility openAIWSHandshakeCompatibilityKey, +) *openAIWSConn { if ap == nil || len(ap.conns) == 0 { return nil } preferredConnID = stringsTrim(preferredConnID) if preferredConnID != "" { - if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesBetaFeatures(betaFeatures) { + if conn, ok := ap.conns[preferredConnID]; ok && conn.matchesHandshakeCompatibility(compatibility) { return conn } } @@ -1344,7 +1422,37 @@ func (p *openAIWSConnPool) pickLeastBusyConnLocked(ap *openAIWSAccountPool, pref var bestWaiters int32 var bestLastUsed time.Time for _, conn := range ap.conns { - if conn == nil || !conn.matchesBetaFeatures(betaFeatures) { + if conn == nil || !conn.matchesHandshakeCompatibility(compatibility) { + continue + } + waiters := conn.waiters.Load() + lastUsed := conn.lastUsedAt() + if best == nil || + waiters < bestWaiters || + (waiters == bestWaiters && lastUsed.Before(bestLastUsed)) { + best = conn + bestWaiters = waiters + bestLastUsed = lastUsed + } + } + return best +} + +func (p *openAIWSConnPool) pickLeastBusyConnWithRoutingAffinityLocked( + ap *openAIWSAccountPool, + compatibility openAIWSHandshakeCompatibilityKey, + routingAffinity string, +) *openAIWSConn { + if ap == nil || len(ap.conns) == 0 { + return nil + } + var best *openAIWSConn + var bestWaiters int32 + var bestLastUsed time.Time + for _, conn := range ap.conns { + if conn == nil || + !conn.matchesHandshakeCompatibility(compatibility) || + !conn.matchesRoutingAffinity(routingAffinity) { continue } waiters := conn.waiters.Load() @@ -1488,12 +1596,20 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ if len(generations) > 0 { generation = generations[0] } + staleTarget := false defer func() { if ap, ok := p.getAccountPool(accountID); ok && ap != nil { ap.mu.Lock() ap.prewarmActive = false + ap.signalChangedLocked() ap.mu.Unlock() } + if staleTarget { + // A newer acquire arrived while the old dial was in flight. Re-run + // target selection only after clearing prewarmActive so the latest + // beta/hint target can fill the idle budget. + p.ensureTargetIdleAsync(accountID) + } }() for i := 0; i < total; i++ { @@ -1524,6 +1640,13 @@ func (p *openAIWSConnPool) prewarmConns(accountID int64, req openAIWSAcquireRequ conn.close() continue } + if !sameOpenAIWSPrewarmTarget(req, *ap.lastAcquire) { + staleTarget = true + ap.signalChangedLocked() + ap.mu.Unlock() + conn.close() + continue + } if len(ap.conns) >= p.effectiveMaxConnsByAccount(req.Account) { ap.signalChangedLocked() ap.mu.Unlock() @@ -1563,6 +1686,7 @@ func (p *openAIWSConnPool) ClearAccount(accountID int64) { ap.prewarmUntil = time.Time{} ap.prewarmFails = 0 ap.prewarmFailAt = time.Time{} + ap.signalChangedLocked() ap.mu.Unlock() closeOpenAIWSConns(conns) } @@ -1676,7 +1800,8 @@ func (p *openAIWSConnPool) dialConn(ctx context.Context, req openAIWSAcquireRequ } id := p.nextConnID(req.Account.ID) pooledConn := newOpenAIWSConn(id, req.Account.ID, conn, handshakeHeaders) - pooledConn.betaFeatures = normalizeOpenAIWSBetaFeatures(req.Headers) + pooledConn.handshakeCompatibility = normalizeOpenAIWSHandshakeCompatibility(req.Headers) + pooledConn.routingAffinity = normalizeOpenAIWSRoutingAffinity(req.Headers) return pooledConn, nil } @@ -1855,6 +1980,12 @@ func cloneOpenAIWSAcquireRequestPtr(req *openAIWSAcquireRequest) *openAIWSAcquir return &copied } +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) +} + func normalizeOpenAIWSBetaFeatures(headers http.Header) string { features := make(map[string]struct{}) for name, values := range headers { @@ -1880,6 +2011,39 @@ func normalizeOpenAIWSBetaFeatures(headers http.Header) string { return strings.Join(normalized, ",") } +func normalizeOpenAIWSHandshakeCompatibility(headers http.Header) openAIWSHandshakeCompatibilityKey { + return openAIWSHandshakeCompatibilityKey{ + betaFeatures: normalizeOpenAIWSBetaFeatures(headers), + } +} + +func normalizeOpenAIWSRoutingAffinity(headers http.Header) string { + canonicalName := http.CanonicalHeaderKey(openAICodexRoutingHintHeader) + if values, ok := headers[canonicalName]; ok { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + + variantNames := make([]string, 0) + for name := range headers { + if name != canonicalName && strings.EqualFold(strings.TrimSpace(name), openAICodexRoutingHintHeader) { + variantNames = append(variantNames, name) + } + } + sort.Strings(variantNames) + for _, name := range variantNames { + for _, value := range headers[name] { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + return "" +} + func cloneHeader(src http.Header) http.Header { if src == nil { return nil diff --git a/backend/internal/service/openai_ws_pool_test.go b/backend/internal/service/openai_ws_pool_test.go index 8d339359ee..f04b81d40d 100644 --- a/backend/internal/service/openai_ws_pool_test.go +++ b/backend/internal/service/openai_ws_pool_test.go @@ -61,6 +61,18 @@ func TestOpenAIWSConnPool_AcquireCleanupInterval(t *testing.T) { require.Less(t, openAIWSAcquireCleanupInterval, openAIWSBackgroundSweepTicker) } +func TestNormalizeOpenAIWSRoutingAffinityPrefersCanonicalAndSortsVariants(t *testing.T) { + headers := http.Header{ + "X-CODEX-ROUTING-HINT": []string{" variant-uppercase "}, + "X-Codex-Routing-Hint": []string{" ", " canonical "}, + } + + require.Equal(t, "canonical", normalizeOpenAIWSRoutingAffinity(headers)) + + delete(headers, "X-Codex-Routing-Hint") + require.Equal(t, "variant-uppercase", normalizeOpenAIWSRoutingAffinity(headers)) +} + func TestOpenAIWSConnLease_WriteJSONAndGuards(t *testing.T) { conn := newOpenAIWSConn("lease_write", 1, &openAIWSFakeConn{}, nil) lease := &openAIWSConnLease{conn: conn} @@ -310,6 +322,219 @@ func TestOpenAIWSConnPool_AcquireQueueWaitMetrics(t *testing.T) { require.GreaterOrEqual(t, metrics.ConnPickTotal, int64(1)) } +func TestOpenAIWSConnPool_DialSuccessWakesTopologyWaiterAndCanceledWaiterDoesNotLoseLease(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 4 + + pool := newOpenAIWSConnPool(cfg) + dialer := newOpenAIWSFirstDialBlockingCaptureDialer() + pool.setClientDialerForTest(dialer) + account := &Account{ID: 991, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + req := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"} + + type result struct { + lease *openAIWSConnLease + err error + } + firstCh := make(chan result, 1) + go func() { + lease, err := pool.Acquire(context.Background(), req) + firstCh <- result{lease: lease, err: err} + }() + <-dialer.firstStarted + + waitCtx, cancelWait := context.WithCancel(context.Background()) + secondCh := make(chan result, 1) + waitReq := req + waitReq.Headers = http.Header{openAICodexRoutingHintHeader: {"model=gpt-5.6-codex;tier=priority"}} + go func() { + lease, err := pool.Acquire(waitCtx, waitReq) + secondCh <- result{lease: lease, err: err} + }() + + close(dialer.releaseFirst) + first := <-firstCh + require.NoError(t, first.err) + require.NotNil(t, first.lease) + + // The second acquire initially waits on the account topology channel while + // the first dial is in flight. Dial success must wake it immediately so it + // can queue on the newly-created (still leased) connection. + require.Eventually(t, func() bool { + ap, ok := pool.getAccountPool(account.ID) + if !ok || ap == nil { + return false + } + ap.mu.Lock() + defer ap.mu.Unlock() + for _, conn := range ap.conns { + if conn != nil && conn.waiters.Load() == 1 { + return true + } + } + return false + }, time.Second, 5*time.Millisecond) + + cancelWait() + second := <-secondCh + require.ErrorIs(t, second.err, context.Canceled) + require.Nil(t, second.lease) + ap, ok := pool.getAccountPool(account.ID) + require.True(t, ok) + ap.mu.Lock() + require.NotNil(t, ap.lastAcquire) + require.Empty(t, normalizeOpenAIWSRoutingAffinity(ap.lastAcquire.Headers), "a canceled acquire must not replace the successful prewarm target") + ap.mu.Unlock() + first.lease.Release() + + third, err := pool.Acquire(context.Background(), req) + require.NoError(t, err) + require.True(t, third.Reused(), "a canceled waiter must not consume the released semaphore token") + require.Equal(t, first.lease.ConnID(), third.ConnID()) + third.Release() +} + +func TestOpenAIWSConnPool_PrewarmHintChangeDoesNotInvalidateHealthyDial(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 2 + + pool := newOpenAIWSConnPool(cfg) + dialer := newOpenAIWSFirstDialBlockingCaptureDialer() + pool.setClientDialerForTest(dialer) + account := &Account{ID: 992, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + oldHeaders := make(http.Header) + oldHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex") + newHeaders := make(http.Header) + newHeaders.Set(openAICodexRoutingHintHeader, "model=gpt-5.6-codex;tier=priority") + ap := pool.getOrCreateAccountPool(account.ID) + ap.mu.Lock() + ap.lastAcquire = &openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: oldHeaders, + } + ap.mu.Unlock() + + pool.ensureTargetIdleAsync(account.ID) + <-dialer.firstStarted + + // Simulate a newer priority target arriving while the old model-only + // prewarm dial is in flight. Routing hints are advisory, so this alone must + // not discard an otherwise compatible connection. + ap.mu.Lock() + ap.lastAcquire = &openAIWSAcquireRequest{ + Account: account, + WSURL: "wss://example.com/v1/responses", + Headers: newHeaders, + } + ap.mu.Unlock() + close(dialer.releaseFirst) + + require.Eventually(t, func() bool { + ap.mu.Lock() + defer ap.mu.Unlock() + if ap.prewarmActive || len(ap.conns) != 1 { + return false + } + for _, conn := range ap.conns { + return conn != nil && conn.routingAffinity == "model=gpt-5.6-codex" + } + return false + }, 2*time.Second, 10*time.Millisecond) + require.Equal(t, 1, dialer.DialCount(), "routing-hint-only changes must not turn advisory metadata into hard reconnects") +} + +func TestOpenAIWSConnPool_ClearAccountWakesIncompatibleTopologyWaiter(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 := &Account{ID: 993, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + baseReq := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"} + betaAReq := baseReq + betaAReq.Headers = http.Header{"X-Codex-Beta-Features": {"feature_a"}} + betaBReq := baseReq + betaBReq.Headers = http.Header{"X-Codex-Beta-Features": {"feature_b"}} + + busy, err := pool.Acquire(context.Background(), betaAReq) + require.NoError(t, err) + require.Equal(t, 1, dialer.DialCount()) + + type result struct { + lease *openAIWSConnLease + err error + } + resultCh := make(chan result, 1) + waitCtx, cancelWait := context.WithTimeout(context.Background(), time.Second) + defer cancelWait() + go func() { + lease, acquireErr := pool.Acquire(waitCtx, betaBReq) + resultCh <- result{lease: lease, err: acquireErr} + }() + + require.Never(t, func() bool { return dialer.DialCount() > 1 }, 50*time.Millisecond, 5*time.Millisecond) + pool.ClearAccount(account.ID) + + resultB := <-resultCh + require.NoError(t, resultB.err) + require.NotNil(t, resultB.lease) + require.False(t, resultB.lease.Reused()) + require.Equal(t, 2, dialer.DialCount(), "ClearAccount must wake the waiter to redial immediately") + resultB.lease.Release() + busy.Release() +} + +func TestOpenAIWSConnPool_ClearAccountDoesNotReviveInFlightDialGeneration(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 := newOpenAIWSFirstDialBlockingCaptureDialer() + pool.setClientDialerForTest(dialer) + account := &Account{ID: 994, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + req := openAIWSAcquireRequest{Account: account, WSURL: "wss://example.com/v1/responses"} + + type result struct { + lease *openAIWSConnLease + err error + } + resultCh := make(chan result, 1) + go func() { + lease, err := pool.Acquire(context.Background(), req) + resultCh <- result{lease: lease, err: err} + }() + <-dialer.firstStarted + + pool.ClearAccount(account.ID) + close(dialer.releaseFirst) + got := <-resultCh + require.NoError(t, got.err) + require.NotNil(t, got.lease) + require.Equal(t, 2, dialer.DialCount(), "the pre-clear dial must be discarded and retried in the new generation") + require.True(t, strings.HasSuffix(got.lease.ConnID(), "_2")) + + ap, ok := pool.getAccountPool(account.ID) + require.True(t, ok) + ap.mu.Lock() + require.Equal(t, uint64(1), ap.generation) + require.Len(t, ap.conns, 1) + require.NotNil(t, ap.lastAcquire, "only the post-clear successful acquire may restore the prewarm target") + ap.mu.Unlock() + got.lease.Release() +} + func TestOpenAIWSConnPool_ForceNewConnSkipsReuse(t *testing.T) { cfg := &config.Config{} cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2 @@ -1468,6 +1693,21 @@ func TestOpenAIWSConn_AdditionalGuardBranches(t *testing.T) { closeOpenAIWSConns([]*openAIWSConn{nil, connOK}) } +func TestOpenAIWSConnPool_CanceledWaiterReturnsDeliveredLease(t *testing.T) { + conn := newOpenAIWSConn("cancelled_delivery", 1, &openAIWSFakeConn{}, nil) + + // Both branches of acquire's select are ready. Before the post-delivery + // cancellation check this intermittently returned nil after consuming the + // only lease token, which made the next pool acquire block forever. + for range 64 { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, conn.acquire(ctx), context.Canceled) + require.True(t, conn.tryAcquire(), "a canceled waiter must return a delivered lease token") + conn.release() + } +} + func TestOpenAIWSConnLease_MarkBrokenEvictsConn(t *testing.T) { pool := newOpenAIWSConnPool(&config.Config{}) accountID := int64(5001) @@ -1602,6 +1842,21 @@ type openAIWSCountingDialer struct { dialCount int } +type openAIWSFirstDialBlockingCaptureDialer struct { + mu sync.Mutex + dialCount int + headers []http.Header + firstStarted chan struct{} + releaseFirst chan struct{} +} + +func newOpenAIWSFirstDialBlockingCaptureDialer() *openAIWSFirstDialBlockingCaptureDialer { + return &openAIWSFirstDialBlockingCaptureDialer{ + firstStarted: make(chan struct{}), + releaseFirst: make(chan struct{}), + } +} + type openAIWSAlwaysFailDialer struct { mu sync.Mutex dialCount int @@ -1671,6 +1926,36 @@ func (d *openAIWSCountingDialer) Dial( return &openAIWSFakeConn{}, 0, nil, nil } +func (d *openAIWSFirstDialBlockingCaptureDialer) Dial( + ctx context.Context, + wsURL string, + headers http.Header, + proxyURL string, +) (openAIWSClientConn, int, http.Header, error) { + _ = wsURL + _ = proxyURL + d.mu.Lock() + d.dialCount++ + dialNumber := d.dialCount + d.headers = append(d.headers, cloneHeader(headers)) + d.mu.Unlock() + if dialNumber == 1 { + close(d.firstStarted) + select { + case <-ctx.Done(): + return nil, 0, nil, ctx.Err() + case <-d.releaseFirst: + } + } + return &openAIWSFakeConn{}, 0, nil, nil +} + +func (d *openAIWSFirstDialBlockingCaptureDialer) DialCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.dialCount +} + func (d *openAIWSCountingDialer) DialCount() int { d.mu.Lock() defer d.mu.Unlock() diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go index b9e62c3f9d..32779a23f3 100644 --- a/backend/internal/service/openai_ws_protocol_forward_test.go +++ b/backend/internal/service/openai_ws_protocol_forward_test.go @@ -1537,7 +1537,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t }, } - body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"input_text","text":"hello"}]}`) + body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"compaction","encrypted_content":"cAAA"},{"type":"input_text","text":"hello"}]}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) @@ -1553,6 +1553,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t require.Len(t, requests, 2) require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id") require.True(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists(), "首轮请求应保留 encrypted reasoning") + require.True(t, gjson.GetBytes(requests[0], `input.1.encrypted_content`).Exists(), "首轮请求应保留 encrypted compaction") require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id") require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 encrypted reasoning item") require.Equal(t, "input_text", gjson.GetBytes(requests[1], `input.0.type`).String()) diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index 235d42331d..9c98a1ea85 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -193,6 +193,20 @@ func (c *openAIWSStateStoreTimeoutProbeCache) DeleteSessionAccountID(ctx context return nil } +func (c *openAIWSStateStoreTimeoutProbeCache) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (c *openAIWSStateStoreTimeoutProbeCache) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (c *openAIWSStateStoreTimeoutProbeCache) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c *openAIWSStateStoreTimeoutProbeCache) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + 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 8d98fc78a3..b3fe6d3084 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -31,6 +31,8 @@ type Usage struct { type RelayResult struct { RequestModel string + ResponseModel string + ResponseModelConflict bool Usage Usage RequestID string TerminalEventType string @@ -42,12 +44,14 @@ type RelayResult struct { } type RelayTurnResult struct { - RequestModel string - Usage Usage - RequestID string - TerminalEventType string - Duration time.Duration - FirstTokenMs *int + RequestModel string + ResponseModel string + ResponseModelConflict bool + Usage Usage + RequestID string + TerminalEventType string + Duration time.Duration + FirstTokenMs *int } type RelayExit struct { @@ -90,6 +94,8 @@ type relayState struct { requestModelMu sync.RWMutex requestModel string lastResponseID string + lastResponseModel string + responseConflict bool terminalEventType string firstTokenMs *int turnTimingByID map[string]*relayTurnTiming @@ -104,17 +110,22 @@ type relayExitSignal struct { } type observedUpstreamEvent struct { - terminal bool - eventType string - responseID string - usage Usage - duration time.Duration - firstToken *int + terminal bool + eventType string + responseID string + usage Usage + responseModel string + responseConflict bool + duration time.Duration + firstToken *int } type relayTurnTiming struct { - startAt time.Time - firstTokenMs *int + startAt time.Time + firstTokenMs *int + firstResponseModel string + terminalResponseModel string + responseModelConflict bool } func Relay( @@ -173,7 +184,13 @@ func Relay( return writeUpstream(msgType, payload) } writeClient := func(msgType coderws.MessageType, payload []byte) error { - writeCtx, cancel := context.WithTimeout(relayCtx, writeTimeout) + // 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write + // ctx 被取消时会直接硬关连接(context.AfterFunc 的 stop 不等待执行中 + // 的回调),外部取消若落在一次已成功写入的解除武装窗口内,会连同尚未 + // 发出的 close 帧一起冲掉,客户端只能看到裸 EOF 而收不到关闭码。与读 + // 侧 conn.Read(context.Background()) 同理,取消路径的连接回收由各退出 + // 分支的显式 Close/CloseNow 兜底。 + writeCtx, cancel := context.WithTimeout(context.Background(), writeTimeout) defer cancel() return clientConn.WriteFrame(writeCtx, msgType, payload) } @@ -678,15 +695,19 @@ func observeUpstreamMessage( responseID: responseID, usage: parsedUsage, } + var turnTiming *relayTurnTiming if responseID != "" { - turnTiming := openAIWSRelayGetOrInitTurnTiming(state, responseID, now) + turnTiming = openAIWSRelayGetOrInitTurnTiming(state, responseID, now) if turnTiming != nil && turnTiming.firstTokenMs == nil && isTokenEvent(eventType) { ms := int(now.Sub(turnTiming.startAt).Milliseconds()) if ms >= 0 { turnTiming.firstTokenMs = &ms } } + } else { + turnTiming = state.activeTurn } + observeRelayTurnResponseModel(turnTiming, firstRelayResponseModel(message), isTerminalEvent(eventType)) if !isTerminalEvent(eventType) { return observed } @@ -695,6 +716,10 @@ func observeUpstreamMessage( if responseID != "" { state.lastResponseID = responseID if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok { + observed.responseModel = relayTurnResponseModel(&turnTiming) + observed.responseConflict = turnTiming.responseModelConflict + state.lastResponseModel = observed.responseModel + state.responseConflict = observed.responseConflict duration := now.Sub(turnTiming.startAt) if duration < 0 { duration = 0 @@ -723,15 +748,64 @@ func emitTurnComplete( requestModel = state.currentRequestModel() } onTurnComplete(RelayTurnResult{ - RequestModel: requestModel, - Usage: observed.usage, - RequestID: responseID, - TerminalEventType: observed.eventType, - Duration: observed.duration, - FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), + RequestModel: requestModel, + ResponseModel: observed.responseModel, + ResponseModelConflict: observed.responseConflict, + Usage: observed.usage, + RequestID: responseID, + TerminalEventType: observed.eventType, + Duration: observed.duration, + FirstTokenMs: openAIWSRelayCloneIntPtr(observed.firstToken), }) } +func firstRelayResponseModel(message []byte) string { + if len(message) == 0 { + return "" + } + values := gjson.GetManyBytes(message, "response.model", "model") + for _, value := range values { + if value.Type != gjson.String { + continue + } + if model := strings.TrimSpace(value.String()); model != "" { + return model + } + } + return "" +} + +func observeRelayTurnResponseModel(turn *relayTurnTiming, model string, terminal bool) { + if turn == nil { + return + } + model = strings.TrimSpace(model) + if model == "" { + return + } + current := relayTurnResponseModel(turn) + if current != "" && !strings.EqualFold(current, model) { + turn.responseModelConflict = true + } + if terminal { + turn.terminalResponseModel = model + return + } + if turn.firstResponseModel == "" { + turn.firstResponseModel = model + } +} + +func relayTurnResponseModel(turn *relayTurnTiming) string { + if turn == nil { + return "" + } + if turn.terminalResponseModel != "" { + return turn.terminalResponseModel + } + return turn.firstResponseModel +} + func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming { if state == nil { return nil @@ -882,6 +956,8 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration return } result.RequestModel = state.currentRequestModel() + result.ResponseModel = state.lastResponseModel + result.ResponseModelConflict = state.responseConflict result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType 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 6036be7451..ed403e3448 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 @@ -457,6 +457,60 @@ func TestRelayTurnTimingHelpersCoverage(t *testing.T) { require.False(t, ok) } +func TestObserveUpstreamMessage_ResponseModelIsTurnLocalAndTerminalWins(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.5"}}`), + startAt, + nowFn, + nil, + ) + require.False(t, created.terminal) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":2}}}`), + startAt, + nowFn, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, "gpt-5.4", completed.responseModel) + require.True(t, completed.responseConflict) + + var firstTurn RelayTurnResult + emitTurnComplete(func(turn RelayTurnResult) { firstTurn = turn }, state, completed) + require.Equal(t, "gpt-5.4", firstTurn.ResponseModel) + require.True(t, firstTurn.ResponseModelConflict) + + observeUpstreamMessage( + state, + []byte(`{"type":"response.created","response":{"id":"resp_2","model":"gpt-5.3"}}`), + startAt, + nowFn, + nil, + ) + second := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_2","model":"GPT-5.3","usage":{"input_tokens":3,"output_tokens":4}}}`), + startAt, + nowFn, + nil, + ) + require.Equal(t, "GPT-5.3", second.responseModel) + require.False(t, second.responseConflict, "the previous turn must not contaminate this turn") +} + func TestObserveUpstreamMessage_ResponseIDFallbackPolicy(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 ae74027bca..775e26980c 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -800,7 +800,19 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader)) } - headers, _, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, wsDecision, isCodexCLI, turnState, turnMetadata, promptCacheKey) + headers, _, buildHdrErr := s.buildOpenAIWSHeaders( + ctx, + c, + account, + token, + wsDecision, + isCodexCLI, + turnState, + turnMetadata, + promptCacheKey, + gjson.GetBytes(firstClientMessage, "model").String(), + gjson.GetBytes(firstClientMessage, "service_tier").String(), + ) if buildHdrErr != nil { return fmt.Errorf("build ws headers: %w", buildHdrErr) } @@ -1103,16 +1115,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: turn.Usage.CacheReadInputTokens, ImageOutputTokens: turn.Usage.ImageOutputTokens, }, - Model: turnRequestModel, - UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: turn.Duration, - FirstTokenMs: turn.FirstTokenMs, + Model: turnRequestModel, + UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), + UpstreamResponseModel: turn.ResponseModel, + UpstreamResponseModelConflict: turn.ResponseModelConflict, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: turn.Duration, + FirstTokenMs: turn.FirstTokenMs, } logOpenAIWSV2Passthrough( "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s turn_requested_model=%s turn_upstream_model=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", @@ -1222,16 +1236,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, ImageOutputTokens: relayResult.Usage.ImageOutputTokens, }, - Model: resultRequestModel, - UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), - ServiceTier: usageMeta.serviceTier.Load(), - ReasoningEffort: usageMeta.reasoningEffort.Load(), - Stream: true, - OpenAIWSMode: true, - UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), - ResponseHeaders: cloneHeader(handshakeHeaders), - Duration: relayResult.Duration, - FirstTokenMs: relayResult.FirstTokenMs, + Model: resultRequestModel, + UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), + UpstreamResponseModel: relayResult.ResponseModel, + UpstreamResponseModelConflict: relayResult.ResponseModelConflict, + ServiceTier: usageMeta.serviceTier.Load(), + ReasoningEffort: usageMeta.reasoningEffort.Load(), + Stream: true, + OpenAIWSMode: true, + UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), + ResponseHeaders: cloneHeader(handshakeHeaders), + Duration: relayResult.Duration, + FirstTokenMs: relayResult.FirstTokenMs, } turnCount := int(completedTurns.Load()) diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index cfd2f1eedc..38e94fb17a 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/robfig/cron/v3" + "go.uber.org/zap" ) const ( @@ -285,7 +286,10 @@ func (s *OpsCleanupService) runScheduled() { return } s.recordHeartbeatSuccess(runAt, time.Since(startedAt), counts) - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cleanup complete: %s", counts) + logger.L().Info("[OpsCleanup] cleanup complete", + zap.String("component", "service.ops_cleanup"), + zap.String("deleted_counts", counts.String()), + ) } func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDeletedCounts, error) { diff --git a/backend/internal/service/ops_scheduled_report_service_test.go b/backend/internal/service/ops_scheduled_report_service_test.go index e4bfcbf05b..0904f10c32 100644 --- a/backend/internal/service/ops_scheduled_report_service_test.go +++ b/backend/internal/service/ops_scheduled_report_service_test.go @@ -131,9 +131,9 @@ func TestOpsScheduledReportLegacyTemplateReceivesSummaryHTML(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, attempts) require.Equal(t, int64(1), smtpServer.messageCount()) - message := smtpServer.lastMessage() - require.Contains(t, message, `
`) - require.Contains(t, message, `

日报

`) + messageBody := smtpServer.lastMessageBody(t) + require.Contains(t, messageBody, `
`) + require.Contains(t, messageBody, `

日报

`) } func TestFormatOpsReportIntegerGroupsDigits(t *testing.T) { diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index 1cbac237c4..7a247a290d 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -34,6 +34,10 @@ type OpsSystemLogSink struct { batchSize int flushInterval time.Duration + // 连续写入失败后的退避参数。构造后只读,测试可在 Start 前覆盖。 + flushBackoff time.Duration + flushBackoffMax time.Duration + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -48,22 +52,54 @@ type OpsSystemLogSink struct { const maxSystemLogHostLength = 255 +const ( + // 首次写入失败后暂停落库的时长,之后逐次翻倍到上限。 + defaultOpsSystemLogFlushBackoff = 2 * time.Second + // 退避上限。日志是尽力而为的观测数据,不值得为它无限期占用连接池。 + defaultOpsSystemLogFlushBackoffMax = 60 * time.Second +) + func NewOpsSystemLogSink(opsRepo OpsRepository) *OpsSystemLogSink { ctx, cancel := context.WithCancel(context.Background()) rawHost, err := os.Hostname() s := &OpsSystemLogSink{ - opsRepo: opsRepo, - host: normalizeSystemLogHost(rawHost, err), - queue: make(chan *logger.LogEvent, 5000), - batchSize: 200, - flushInterval: time.Second, - ctx: ctx, - cancel: cancel, + opsRepo: opsRepo, + host: normalizeSystemLogHost(rawHost, err), + queue: make(chan *logger.LogEvent, 5000), + batchSize: 200, + flushInterval: time.Second, + flushBackoff: defaultOpsSystemLogFlushBackoff, + flushBackoffMax: defaultOpsSystemLogFlushBackoffMax, + ctx: ctx, + cancel: cancel, } s.lastError.Store("") return s } +// flushBackoffFor 返回第 failures 次连续失败后的退避时长(指数退避,封顶)。 +func (s *OpsSystemLogSink) flushBackoffFor(failures int) time.Duration { + base := s.flushBackoff + if base <= 0 { + base = defaultOpsSystemLogFlushBackoff + } + maxBackoff := s.flushBackoffMax + if maxBackoff <= 0 { + maxBackoff = defaultOpsSystemLogFlushBackoffMax + } + if maxBackoff < base { + maxBackoff = base + } + backoff := base + for i := 1; i < failures && backoff < maxBackoff; i++ { + backoff *= 2 + } + if backoff > maxBackoff { + backoff = maxBackoff + } + return backoff +} + func normalizeSystemLogHost(host string, err error) string { host = strings.TrimSpace(host) if err != nil || host == "" { @@ -146,20 +182,37 @@ func (s *OpsSystemLogSink) run() { defer ticker.Stop() batch := make([]*logger.LogEvent, 0, s.batchSize) + // 仅在本 goroutine 内读写,无需加锁。 + failures := 0 + var suppressedUntil time.Time flush := func(baseCtx context.Context) { if len(batch) == 0 { return } + now := time.Now() + if now.Before(suppressedUntil) { + // 退避窗口内直接丢弃本批:日志是尽力而为的观测数据,继续攒批只会把 + // 压力转移到内存,而每次重试都会再占用并取消一条池内连接。 + atomic.AddUint64(&s.droppedCount, uint64(len(batch))) + batch = batch[:0] + return + } started := time.Now() inserted, err := s.flushBatch(baseCtx, batch) delay := time.Since(started) if err != nil { + failures++ + backoff := s.flushBackoffFor(failures) + suppressedUntil = time.Now().Add(backoff) atomic.AddUint64(&s.writeFailed, uint64(len(batch))) s.lastError.Store(err.Error()) - _, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"ops system log sink flush failed\" err=%v batch=%d\n", - time.Now().Format(time.RFC3339Nano), err, len(batch), + // 每个退避窗口至多一条,避免数据库故障期间刷屏。 + _, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"ops system log sink flush failed\" err=%v batch=%d failures=%d backoff=%s\n", + time.Now().Format(time.RFC3339Nano), err, len(batch), failures, backoff, ) } else { + failures = 0 + suppressedUntil = time.Time{} atomic.AddUint64(&s.writtenCount, uint64(inserted)) atomic.AddUint64(&s.totalDelayNs, uint64(delay.Nanoseconds())) s.lastError.Store("") diff --git a/backend/internal/service/ops_system_log_sink_backoff_test.go b/backend/internal/service/ops_system_log_sink_backoff_test.go new file mode 100644 index 0000000000..3a29cb6d0d --- /dev/null +++ b/backend/internal/service/ops_system_log_sink_backoff_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +func opsSystemLogBackoffEvent() *logger.LogEvent { + return &logger.LogEvent{ + Time: time.Now().UTC(), + Level: "warn", + Component: "app", + Message: "boom", + Fields: map[string]any{}, + } +} + +// pumpOpsSystemLogEvents 持续投递日志事件,模拟故障期间业务侧不断产生 WARN/ERROR。 +func pumpOpsSystemLogEvents(t *testing.T, sink *OpsSystemLogSink) { + t.Helper() + stop := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-stop: + return + default: + } + sink.WriteLogEvent(opsSystemLogBackoffEvent()) + time.Sleep(2 * time.Millisecond) + } + }() + t.Cleanup(func() { + close(stop) + <-done + }) +} + +func waitForOpsSystemLogCondition(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(5 * time.Millisecond) + } + return cond() +} + +func TestOpsSystemLogSinkFlushBackoffFor(t *testing.T) { + sink := &OpsSystemLogSink{flushBackoff: time.Second, flushBackoffMax: 8 * time.Second} + + cases := []struct { + name string + failures int + want time.Duration + }{ + {"first_failure", 1, time.Second}, + {"second_failure", 2, 2 * time.Second}, + {"third_failure", 3, 4 * time.Second}, + {"fourth_failure", 4, 8 * time.Second}, + {"capped", 9, 8 * time.Second}, + {"large_streak_does_not_overflow", 1000, 8 * time.Second}, + {"zero_streak_uses_base", 0, time.Second}, + {"negative_streak_uses_base", -1, time.Second}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sink.flushBackoffFor(tc.failures); got != tc.want { + t.Fatalf("flushBackoffFor(%d) = %s, want %s", tc.failures, got, tc.want) + } + }) + } +} + +// 未显式配置时回落到默认值;max 小于 base 时以 base 为准,不产生比基准还短的退避。 +func TestOpsSystemLogSinkFlushBackoffForFallbacks(t *testing.T) { + zero := &OpsSystemLogSink{} + if got := zero.flushBackoffFor(1); got != defaultOpsSystemLogFlushBackoff { + t.Fatalf("zero-value base = %s, want %s", got, defaultOpsSystemLogFlushBackoff) + } + if got := zero.flushBackoffFor(100); got != defaultOpsSystemLogFlushBackoffMax { + t.Fatalf("zero-value cap = %s, want %s", got, defaultOpsSystemLogFlushBackoffMax) + } + + inverted := &OpsSystemLogSink{flushBackoff: 5 * time.Second, flushBackoffMax: time.Second} + if got := inverted.flushBackoffFor(3); got != 5*time.Second { + t.Fatalf("inverted bounds = %s, want 5s", got) + } +} + +// issue #5265:写入失败后如果按 flushInterval 继续每秒重试,每一轮都会占用并取消 +// 一条池内连接(远程 PG 上 COPY 取消会让连接协议失步而被销毁),小连接池会被日志 +// 通道长期占满,业务侧最终报 Billing 503。失败后必须退避。 +func TestOpsSystemLogSinkSuppressesRetriesDuringBackoff(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, _ []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return 0, errors.New("db unavailable") + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 800 * time.Millisecond + sink.flushBackoffMax = 800 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 2*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 1 }) { + t.Fatalf("first flush never happened") + } + + // 退避窗口内不得再打上游:修复前 5ms 的 flushInterval 会在这 300ms 里打出上百次。 + time.Sleep(300 * time.Millisecond) + if got := atomic.LoadInt64(&calls); got != 1 { + t.Fatalf("upstream calls during backoff = %d, want 1", got) + } + + // 被抑制的批次记为 dropped,而不是 write_failed —— 没有尝试过就不算写失败。 + health := sink.Health() + if health.DroppedCount == 0 { + t.Fatalf("dropped_count should grow while flushing is suppressed") + } + if health.WriteFailed == 0 || health.LastError == "" { + t.Fatalf("failed flush should still surface in health: %+v", health) + } +} + +// 退避到期后必须自动恢复,不能变成永久停写。 +func TestOpsSystemLogSinkResumesAfterBackoffWindow(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, _ []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return 0, errors.New("db unavailable") + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 150 * time.Millisecond + sink.flushBackoffMax = 150 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 3 }) { + t.Fatalf("sink did not resume flushing after backoff, calls=%d", atomic.LoadInt64(&calls)) + } +} + +// 一次成功必须清空失败计数与抑制窗口,否则短暂抖动后写入速率无法恢复。 +func TestOpsSystemLogSinkSuccessClearsSuppression(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) { + if atomic.AddInt64(&calls, 1) == 1 { + return 0, errors.New("db unavailable") + } + return int64(len(inputs)), nil + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = 150 * time.Millisecond + sink.flushBackoffMax = 150 * time.Millisecond + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + // 第 2 次调用(恢复后的首次成功)之后不应再有抑制:调用次数需要快速爬升。 + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 2 }) { + t.Fatalf("sink never retried after the first failure") + } + recovered := atomic.LoadInt64(&calls) + time.Sleep(200 * time.Millisecond) + if got := atomic.LoadInt64(&calls) - recovered; got < 3 { + t.Fatalf("calls after recovery = %d in 200ms, want >=3 (suppression not cleared)", got) + } + if sink.Health().WrittenCount == 0 { + t.Fatalf("written_count should grow after recovery") + } +} + +// 健康路径不受影响:一直成功就永远不进入退避。 +func TestOpsSystemLogSinkHealthyPathNeverSuppressed(t *testing.T) { + var calls int64 + repo := &opsRepoMock{ + BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) { + atomic.AddInt64(&calls, 1) + return int64(len(inputs)), nil + }, + } + + sink := NewOpsSystemLogSink(repo) + sink.batchSize = 1 + sink.flushInterval = 5 * time.Millisecond + sink.flushBackoff = time.Hour + sink.flushBackoffMax = time.Hour + sink.Start() + defer sink.Stop() + pumpOpsSystemLogEvents(t, sink) + + if !waitForOpsSystemLogCondition(t, 3*time.Second, func() bool { return atomic.LoadInt64(&calls) >= 10 }) { + t.Fatalf("healthy sink should keep flushing, calls=%d", atomic.LoadInt64(&calls)) + } + if got := sink.Health().DroppedCount; got != 0 { + t.Fatalf("healthy sink dropped_count = %d, want 0", got) + } +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index d79a46ad6b..aac1cc8e26 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -343,38 +343,87 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places") } } - m := map[string]string{ - SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled), - SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount), - SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount), - SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit), - SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin), - SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders), - SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled), - SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier), - SettingSubscriptionUSDToCNYRate: formatPositiveFloatExact(req.SubscriptionUSDToCNYRate), - SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate), - SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy), - SettingProductNamePrefix: derefStr(req.ProductNamePrefix), - SettingProductNameSuffix: derefStr(req.ProductNameSuffix), - SettingHelpImageURL: derefStr(req.HelpImageURL), - SettingHelpText: derefStr(req.HelpText), - SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled), - SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax), - SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), - SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), - SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), - SettingAlipayForceQRCode: formatBoolOrEmpty(req.AlipayForceQRCode), - SettingAlipayMobilePrecreateDeepLink: formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink), - SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource), - SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource), - SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled), - SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled), + m := make(map[string]string) + if req.Enabled != nil { + m[SettingPaymentEnabled] = formatBoolOrEmpty(req.Enabled) + } + if req.MinAmount != nil { + m[SettingMinRechargeAmount] = formatPositiveFloat(req.MinAmount) + } + if req.MaxAmount != nil { + m[SettingMaxRechargeAmount] = formatPositiveFloat(req.MaxAmount) + } + if req.DailyLimit != nil { + m[SettingDailyRechargeLimit] = formatPositiveFloat(req.DailyLimit) + } + if req.OrderTimeoutMin != nil { + m[SettingOrderTimeoutMinutes] = formatPositiveInt(req.OrderTimeoutMin) + } + if req.MaxPendingOrders != nil { + m[SettingMaxPendingOrders] = formatPositiveInt(req.MaxPendingOrders) } if req.EnabledTypes != nil { m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") - } else { - m[SettingEnabledPaymentTypes] = "" + } + if req.BalanceDisabled != nil { + m[SettingBalancePayDisabled] = formatBoolOrEmpty(req.BalanceDisabled) + } + if req.BalanceRechargeMultiplier != nil { + m[SettingBalanceRechargeMult] = formatPositiveFloat(req.BalanceRechargeMultiplier) + } + if req.SubscriptionUSDToCNYRate != nil { + m[SettingSubscriptionUSDToCNYRate] = formatPositiveFloatExact(req.SubscriptionUSDToCNYRate) + } + if req.RechargeFeeRate != nil { + m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate) + } + if req.LoadBalanceStrategy != nil { + m[SettingLoadBalanceStrategy] = derefStr(req.LoadBalanceStrategy) + } + if req.ProductNamePrefix != nil { + m[SettingProductNamePrefix] = derefStr(req.ProductNamePrefix) + } + if req.ProductNameSuffix != nil { + m[SettingProductNameSuffix] = derefStr(req.ProductNameSuffix) + } + if req.HelpImageURL != nil { + m[SettingHelpImageURL] = derefStr(req.HelpImageURL) + } + if req.HelpText != nil { + m[SettingHelpText] = derefStr(req.HelpText) + } + if req.CancelRateLimitEnabled != nil { + m[SettingCancelRateLimitOn] = formatBoolOrEmpty(req.CancelRateLimitEnabled) + } + if req.CancelRateLimitMax != nil { + m[SettingCancelRateLimitMax] = formatPositiveInt(req.CancelRateLimitMax) + } + if req.CancelRateLimitWindow != nil { + m[SettingCancelWindowSize] = formatPositiveInt(req.CancelRateLimitWindow) + } + if req.CancelRateLimitUnit != nil { + m[SettingCancelWindowUnit] = derefStr(req.CancelRateLimitUnit) + } + if req.CancelRateLimitMode != nil { + m[SettingCancelWindowMode] = derefStr(req.CancelRateLimitMode) + } + if req.AlipayForceQRCode != nil { + m[SettingAlipayForceQRCode] = formatBoolOrEmpty(req.AlipayForceQRCode) + } + if req.AlipayMobilePrecreateDeepLink != nil { + m[SettingAlipayMobilePrecreateDeepLink] = formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink) + } + if req.VisibleMethodAlipaySource != nil { + m[SettingPaymentVisibleMethodAlipaySource] = derefStr(req.VisibleMethodAlipaySource) + } + if req.VisibleMethodWxpaySource != nil { + m[SettingPaymentVisibleMethodWxpaySource] = derefStr(req.VisibleMethodWxpaySource) + } + if req.VisibleMethodAlipayEnabled != nil { + m[SettingPaymentVisibleMethodAlipayEnabled] = formatBoolOrEmpty(req.VisibleMethodAlipayEnabled) + } + if req.VisibleMethodWxpayEnabled != nil { + m[SettingPaymentVisibleMethodWxpayEnabled] = formatBoolOrEmpty(req.VisibleMethodWxpayEnabled) } return s.settingRepo.SetMultiple(ctx, m) } diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index 87b1794381..900866ef90 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -441,7 +441,12 @@ func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]strin func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil } func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) { - repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + repo := &paymentConfigSettingRepoStub{values: map[string]string{ + SettingPaymentVisibleMethodAlipayEnabled: "false", + SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay, + SettingPaymentVisibleMethodWxpayEnabled: "true", + SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceEasyPayWechat, + }} svc := &PaymentConfigService{settingRepo: repo} alipayEnabled := true @@ -470,6 +475,82 @@ func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) { } } +func TestUpdatePaymentConfig_OmittedVisibleMethodRoutingIsPreserved(t *testing.T) { + wantVisibleMethods := map[string]string{ + SettingPaymentVisibleMethodAlipayEnabled: "true", + SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay, + SettingPaymentVisibleMethodWxpayEnabled: "false", + SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat, + } + initial := make(map[string]string, len(wantVisibleMethods)) + for key, value := range wantVisibleMethods { + initial[key] = value + } + repo := &paymentConfigSettingRepoStub{values: initial} + svc := &PaymentConfigService{settingRepo: repo} + + enabled := true + err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{Enabled: &enabled}) + if err != nil { + t.Fatalf("UpdatePaymentConfig returned error: %v", err) + } + + visibleMethodKeys := []string{ + SettingPaymentVisibleMethodAlipayEnabled, + SettingPaymentVisibleMethodAlipaySource, + SettingPaymentVisibleMethodWxpayEnabled, + SettingPaymentVisibleMethodWxpaySource, + } + for _, key := range visibleMethodKeys { + if _, ok := repo.updates[key]; ok { + t.Fatalf("omitted visible method setting %q was written", key) + } + if repo.values[key] != wantVisibleMethods[key] { + t.Fatalf("visible method setting %q = %q, want preserved value %q", key, repo.values[key], wantVisibleMethods[key]) + } + } + if repo.updates[SettingPaymentEnabled] != "true" { + t.Fatalf("payment enabled update = %q, want true", repo.updates[SettingPaymentEnabled]) + } +} + +func TestUpdatePaymentConfig_PersistsExplicitEmptyAndFalseValues(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{ + SettingEnabledPaymentTypes: "alipay,wxpay", + SettingBalancePayDisabled: "true", + SettingProductNamePrefix: "existing", + }} + svc := &PaymentConfigService{settingRepo: repo} + + falseValue := false + emptyString := "" + err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{ + EnabledTypes: []string{}, + BalanceDisabled: &falseValue, + ProductNamePrefix: &emptyString, + }) + if err != nil { + t.Fatalf("UpdatePaymentConfig returned error: %v", err) + } + + want := map[string]string{ + SettingEnabledPaymentTypes: "", + SettingBalancePayDisabled: "false", + SettingProductNamePrefix: "", + } + if len(repo.updates) != len(want) { + t.Fatalf("updates = %v, want exactly %v", repo.updates, want) + } + for key, value := range want { + if repo.updates[key] != value { + t.Fatalf("update %q = %q, want %q", key, repo.updates[key], value) + } + if repo.values[key] != value { + t.Fatalf("stored %q = %q, want %q", key, repo.values[key], value) + } + } +} + func paymentConfigStrPtr(value string) *string { return &value } diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index bc073a2c34..e49c54ec59 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -276,10 +276,25 @@ func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, return nil } p.DeductionType = payment.DeductionTypeBalance - p.BalanceToDeduct = math.Min(p.RefundAmount, u.Balance) + if u.Balance < p.RefundAmount && !force { + return &RefundResult{Success: false, Warning: "user balance is insufficient for deduction, use force", RequireForce: true} + } + p.BalanceToDeduct = math.Max(0, math.Min(p.RefundAmount, u.Balance)) return nil } +type availableBalanceDeductor interface { + DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error) +} + +func (s *PaymentService) deductAvailableBalance(ctx context.Context, userID int64, amount float64) (float64, error) { + repo, ok := s.userRepo.(availableBalanceDeductor) + if !ok { + return 0, errors.New("user repository does not support available balance deduction") + } + return repo.DeductAvailableBalance(ctx, userID, amount) +} + func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) { c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundPending, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx) if err != nil { @@ -292,10 +307,12 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref // Skip balance deduction on retry if previous attempt already deducted // but failed to roll back (REFUND_ROLLBACK_FAILED in audit log). if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") { - if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { + deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct) + if err != nil { s.restoreStatus(ctx, p) return nil, fmt.Errorf("deduction: %w", err) } + p.BalanceToDeduct = deducted } else { slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID) p.BalanceToDeduct = 0 @@ -446,10 +463,7 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) } switch strings.TrimSpace(resp.Status) { case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded: - if err := s.applyRefundFinalDeduction(ctx, plan); err != nil { - return nil, err - } - return s.markRefundOk(ctx, plan) + return s.finalizePendingRefundSuccess(ctx, plan) case payment.ProviderStatusPending: s.writeAuditLog(ctx, oid, "REFUND_QUERY_PENDING", "admin", map[string]any{"refundID": resp.RefundID}) return &RefundResult{Success: false, Warning: "gateway refund is still pending confirmation"}, nil @@ -458,6 +472,42 @@ func (s *PaymentService) QueryAndFinalizeRefund(ctx context.Context, oid int64) } } +func (s *PaymentService) finalizePendingRefundSuccess(ctx context.Context, p *RefundPlan) (_ *RefundResult, err error) { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin refund finalization: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + txCtx := dbent.NewTxContext(ctx, tx) + + claimed, err := tx.PaymentOrder.Update(). + Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusEQ(OrderStatusRefundPending)). + SetStatus(OrderStatusRefunding). + Save(txCtx) + if err != nil { + return nil, fmt.Errorf("claim pending refund: %w", err) + } + if claimed == 0 { + return nil, infraerrors.Conflict("CONFLICT", "order status changed") + } + + if err := s.applyRefundFinalDeduction(txCtx, p); err != nil { + return nil, err + } + result, err := s.markRefundOkTx(txCtx, tx.Client(), p) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, fmt.Errorf("commit refund finalization: %w", err) + } + return result, nil +} + func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan { refundAmount := o.RefundAmount reason := strings.TrimSpace(psStringValue(o.RefundReason)) @@ -483,15 +533,12 @@ func (s *PaymentService) refundFinalizePlan(o *dbent.PaymentOrder) *RefundPlan { } func (s *PaymentService) applyRefundFinalDeduction(ctx context.Context, p *RefundPlan) error { - if s.hasAuditLog(ctx, p.OrderID, "REFUND_SUCCESS") { - p.BalanceToDeduct = 0 - p.SubDaysToDeduct = 0 - return nil - } if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { - if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { + deducted, err := s.deductAvailableBalance(ctx, p.Order.UserID, p.BalanceToDeduct) + if err != nil { return fmt.Errorf("deduction: %w", err) } + p.BalanceToDeduct = deducted } if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 { if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct); err != nil { @@ -572,6 +619,31 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil } +func (s *PaymentService) markRefundOkTx(ctx context.Context, client *dbent.Client, p *RefundPlan) (*RefundResult, error) { + fs := OrderStatusRefunded + if p.RefundAmount < p.Order.Amount { + fs = OrderStatusPartiallyRefunded + } + now := time.Now() + _, err := client.PaymentOrder.UpdateOneID(p.OrderID).SetStatus(fs).SetRefundAmount(p.RefundAmount).SetRefundReason(p.Reason).SetRefundAt(now).SetForceRefund(p.Force).Save(ctx) + if err != nil { + return nil, fmt.Errorf("mark refund: %w", err) + } + detail, err := json.Marshal(map[string]any{"refundAmount": p.RefundAmount, "reason": p.Reason, "balanceDeducted": p.BalanceToDeduct, "force": p.Force}) + if err != nil { + return nil, fmt.Errorf("marshal refund audit: %w", err) + } + if _, err := client.PaymentAuditLog.Create(). + SetOrderID(strconv.FormatInt(p.OrderID, 10)). + SetAction("REFUND_SUCCESS"). + SetDetail(string(detail)). + SetOperator("admin"). + Save(ctx); err != nil { + return nil, fmt.Errorf("write refund audit: %w", err) + } + return &RefundResult{Success: true, BalanceDeducted: p.BalanceToDeduct, SubDaysDeducted: p.SubDaysToDeduct}, nil +} + func (s *PaymentService) markRefundPending(ctx context.Context, p *RefundPlan, resp *payment.RefundResponse) (*RefundResult, error) { balanceDeducted := p.BalanceToDeduct subDaysDeducted := p.SubDaysToDeduct diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go index dc9c3fa0de..8a534e473e 100644 --- a/backend/internal/service/payment_refund_test.go +++ b/backend/internal/service/payment_refund_test.go @@ -4,6 +4,8 @@ package service import ( "context" + "errors" + "fmt" "strconv" "testing" "time" @@ -119,6 +121,93 @@ func TestPrepareRefundRejectsLegacyGuessedProviderInstance(t *testing.T) { require.Equal(t, "REFUND_DISABLED", infraerrors.Reason(err)) } +func TestPrepDeductBalanceRequiresForceWhenBalanceIsInsufficient(t *testing.T) { + for _, tc := range []struct { + name string + balance float64 + force bool + wantDeduct float64 + wantWarning bool + }{ + {name: "insufficient balance", balance: 40, wantWarning: true}, + {name: "forced insufficient balance", balance: 40, force: true, wantDeduct: 40}, + {name: "equal balance", balance: 100, wantDeduct: 100}, + } { + t.Run(tc.name, func(t *testing.T) { + plan := &RefundPlan{RefundAmount: 100} + svc := &PaymentService{userRepo: &mockUserRepo{getByIDUser: &User{Balance: tc.balance}}} + + result := svc.prepDeduct(context.Background(), &dbent.PaymentOrder{ + UserID: 1, + OrderType: payment.OrderTypeBalance, + }, plan, tc.force) + + if tc.wantWarning { + require.NotNil(t, result) + require.False(t, result.Success) + require.True(t, result.RequireForce) + require.Equal(t, "user balance is insufficient for deduction, use force", result.Warning) + require.Zero(t, plan.BalanceToDeduct) + return + } + require.Nil(t, result) + require.Equal(t, payment.DeductionTypeBalance, plan.DeductionType) + require.Equal(t, tc.wantDeduct, plan.BalanceToDeduct) + }) + } +} + +func TestExecuteRefundUsesActualAvailableBalanceDeduction(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + user, err := client.User.Create(). + SetEmail("refund-execute-clamp@example.com"). + SetPasswordHash("hash"). + SetUsername("refund-execute-clamp"). + Save(ctx) + require.NoError(t, err) + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(100). + SetPayAmount(100). + SetFeeRate(0). + SetRechargeCode("REFUND-EXECUTE-CLAMP"). + SetOutTradeNo("refund_execute_clamp"). + SetPaymentType(payment.TypeStripe). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusCompleted). + SetExpiresAt(time.Now().Add(time.Hour)). + SetPaidAt(time.Now()). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + repo := &mockUserRepo{deductAvailableBalanceFn: func(_ context.Context, id int64, amount float64) (float64, error) { + require.Equal(t, user.ID, id) + require.Equal(t, 100.0, amount) + return 25, nil + }} + plan := &RefundPlan{ + OrderID: order.ID, Order: order, RefundAmount: 100, GatewayAmount: 100, + Reason: "concurrent spend", Force: true, DeductionType: payment.DeductionTypeBalance, BalanceToDeduct: 100, + } + + result, err := (&PaymentService{entClient: client, userRepo: repo}).ExecuteRefund(ctx, plan) + require.NoError(t, err) + require.True(t, result.Success) + require.Equal(t, 25.0, plan.BalanceToDeduct) + require.Equal(t, 25.0, result.BalanceDeducted) + audit, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Only(ctx) + require.NoError(t, err) + require.Contains(t, audit.Detail, `"balanceDeducted":25`) +} + func TestGwRefundRejectsAlipayMerchantIdentitySnapshotMismatch(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -366,8 +455,10 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { status string wantStatus string wantDeduct float64 + available float64 }{ - {name: "success", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 100}, + {name: "success", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 100, available: 100}, + {name: "success clamps current balance", status: payment.ProviderStatusSuccess, wantStatus: OrderStatusRefunded, wantDeduct: 35, available: 35}, {name: "failed", status: payment.ProviderStatusFailed, wantStatus: OrderStatusRefundFailed}, {name: "pending", status: payment.ProviderStatusPending, wantStatus: OrderStatusRefundPending}, } { @@ -380,9 +471,9 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { svc := &PaymentService{ entClient: client, loadBalancer: &captureLoadBalancer{}, - userRepo: &mockUserRepo{deductBalanceFn: func(ctx context.Context, id int64, amount float64) error { - deducted += amount - return nil + userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) { + deducted += tc.available + return tc.available, nil }}, } restore := replacePaymentProviderFactoryForTest(t, &refundQueryProviderTestDouble{ @@ -395,6 +486,14 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { require.NotNil(t, result) require.Equal(t, tc.status == payment.ProviderStatusSuccess, result.Success) require.Equal(t, tc.wantDeduct, deducted) + if tc.status == payment.ProviderStatusSuccess { + require.Equal(t, tc.wantDeduct, result.BalanceDeducted) + audit, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Only(ctx) + require.NoError(t, err) + require.Contains(t, audit.Detail, fmt.Sprintf(`"balanceDeducted":%v`, tc.wantDeduct)) + } reloaded, err := client.PaymentOrder.Get(ctx, order.ID) require.NoError(t, err) @@ -403,6 +502,74 @@ func TestQueryAndFinalizeRefundFinalizesProviderStatuses(t *testing.T) { } } +func TestFinalizePendingRefundSuccessRejectsStaleCallerBeforeSecondDeduction(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderForTest(t, ctx, client, "finalize-stale") + + deductions := 0 + svc := &PaymentService{ + entClient: client, + userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) { + require.NotNil(t, dbent.TxFromContext(ctx)) + deductions++ + return amount, nil + }}, + } + + first, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order)) + require.NoError(t, err) + require.True(t, first.Success) + + second, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order)) + require.Nil(t, second) + require.Error(t, err) + require.Equal(t, "CONFLICT", infraerrors.Reason(err)) + require.Equal(t, 1, deductions) + + successAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, successAudits) +} + +func TestFinalizePendingRefundSuccessRollsBackPostDeductionFailure(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + order := createPendingRefundOrderForTest(t, ctx, client, "finalize-rollback") + _, err := client.User.UpdateOneID(order.UserID).SetBalance(100).Save(ctx) + require.NoError(t, err) + + svc := &PaymentService{ + entClient: client, + userRepo: &mockUserRepo{deductAvailableBalanceFn: func(ctx context.Context, id int64, amount float64) (float64, error) { + tx := dbent.TxFromContext(ctx) + require.NotNil(t, tx) + if _, updateErr := tx.Client().User.UpdateOneID(id).AddBalance(-amount).Save(ctx); updateErr != nil { + return 0, updateErr + } + return 0, errors.New("injected failure after deduction") + }}, + } + + result, err := svc.finalizePendingRefundSuccess(ctx, svc.refundFinalizePlan(order)) + require.Nil(t, result) + require.ErrorContains(t, err, "injected failure after deduction") + + user, err := client.User.Get(ctx, order.UserID) + require.NoError(t, err) + require.Equal(t, 100.0, user.Balance) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusRefundPending, reloaded.Status) + successAudits, err := client.PaymentAuditLog.Query(). + Where(paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)), paymentauditlog.ActionEQ("REFUND_SUCCESS")). + Count(ctx) + require.NoError(t, err) + require.Zero(t, successAudits) +} + func TestQueryAndFinalizeRefundUnsupportedProviderReturnsClearError(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index c969ec18d1..f7733ae001 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -53,14 +53,14 @@ var ( SupportsPromptCaching: true, } openAIGPT56TerraFallbackPricing = &LiteLLMModelPricing{ - InputCostPerToken: 2.5e-06, - InputCostPerTokenPriority: 5e-06, - OutputCostPerToken: 1.5e-05, - OutputCostPerTokenPriority: 3e-05, - CacheCreationInputTokenCost: 3.125e-06, - CacheCreationInputTokenCostPriority: 6.25e-06, - CacheReadInputTokenCost: 2.5e-07, - CacheReadInputTokenCostPriority: 5e-07, + InputCostPerToken: 2e-06, + InputCostPerTokenPriority: 4e-06, + OutputCostPerToken: 1.2e-05, + OutputCostPerTokenPriority: 2.4e-05, + CacheCreationInputTokenCost: 2.5e-06, + CacheCreationInputTokenCostPriority: 5e-06, + CacheReadInputTokenCost: 2e-07, + CacheReadInputTokenCostPriority: 4e-07, LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, @@ -70,14 +70,14 @@ var ( SupportsPromptCaching: true, } openAIGPT56LunaFallbackPricing = &LiteLLMModelPricing{ - InputCostPerToken: 1e-06, - InputCostPerTokenPriority: 2e-06, - OutputCostPerToken: 6e-06, - OutputCostPerTokenPriority: 1.2e-05, - CacheCreationInputTokenCost: 1.25e-06, - CacheCreationInputTokenCostPriority: 2.5e-06, - CacheReadInputTokenCost: 1e-07, - CacheReadInputTokenCostPriority: 2e-07, + InputCostPerToken: 2e-07, + InputCostPerTokenPriority: 4e-07, + OutputCostPerToken: 1.2e-06, + OutputCostPerTokenPriority: 2.4e-06, + CacheCreationInputTokenCost: 2.5e-07, + CacheCreationInputTokenCostPriority: 5e-07, + CacheReadInputTokenCost: 2e-08, + CacheReadInputTokenCostPriority: 4e-08, LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, @@ -652,6 +652,33 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing modelLower := strings.ToLower(strings.TrimSpace(modelName)) lookupCandidates := s.buildModelLookupCandidates(modelLower) + // 1~3. 确定性识别(精确名 / 已知拼写变体 / 去掉日期版本后缀) + if pricing := s.lookupIdentifiedModelPricingLocked(lookupCandidates); pricing != nil { + return pricing + } + + // 4. 基于模型系列匹配(Claude) + if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil { + return pricing + } + + // 5. OpenAI 模型回退策略 + if strings.HasPrefix(lookupCandidates[0], "gpt-") { + return s.matchOpenAIModel(lookupCandidates[0]) + } + + return nil +} + +// lookupIdentifiedModelPricingLocked 只做"确定性识别"的三步查找:精确键、已知拼写 +// 变体、去掉日期/版本后缀后的同名条目。它刻意不包含 matchByModelFamily / +// matchOpenAIModel 这类按子串猜系列的兜底——那些兜底会给任意名字都返回一个价格。 +// 调用方必须持有 s.mu 读锁。 +func (s *PricingService) lookupIdentifiedModelPricingLocked(lookupCandidates []string) *LiteLLMModelPricing { + if len(lookupCandidates) == 0 { + return nil + } + // 1. 精确匹配 for _, candidate := range lookupCandidates { if candidate == "" { @@ -681,19 +708,26 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing } } - // 4. 基于模型系列匹配(Claude) - if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil { - return pricing - } - - // 5. OpenAI 模型回退策略 - if strings.HasPrefix(lookupCandidates[0], "gpt-") { - return s.matchOpenAIModel(lookupCandidates[0]) - } - return nil } +// GetIdentifiedModelPricing 在价格表中确定性地识别模型,识别不到时返回 nil。 +// 与 GetModelPricing 的区别:不会退化成按 "opus"/"haiku" 之类子串猜出的系列兜底价。 +// 用于必须区分"这是价格表里已知的模型"和"这只是名字里带某个关键词"的场景。 +func (s *PricingService) GetIdentifiedModelPricing(modelName string) *LiteLLMModelPricing { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + modelLower := strings.ToLower(strings.TrimSpace(modelName)) + if modelLower == "" { + return nil + } + return s.lookupIdentifiedModelPricingLocked(s.buildModelLookupCandidates(modelLower)) +} + func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { rawCandidates := []string{ modelLower, diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 48e5159ad3..1a933aec10 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -88,8 +88,8 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing. cacheReadPriority float64 }{ {model: "gpt-5.6-sol", input: 5e-6, inputPriority: 10e-6, output: 30e-6, outputPriority: 60e-6, cacheRead: 0.5e-6, cacheReadPriority: 1e-6}, - {model: "gpt-5.6-terra", input: 2.5e-6, inputPriority: 5e-6, output: 15e-6, outputPriority: 30e-6, cacheRead: 0.25e-6, cacheReadPriority: 0.5e-6}, - {model: "gpt-5.6-luna", input: 1e-6, inputPriority: 2e-6, output: 6e-6, outputPriority: 12e-6, cacheRead: 0.1e-6, cacheReadPriority: 0.2e-6}, + {model: "gpt-5.6-terra", input: 2e-6, inputPriority: 4e-6, output: 12e-6, outputPriority: 24e-6, cacheRead: 0.2e-6, cacheReadPriority: 0.4e-6}, + {model: "gpt-5.6-luna", input: 0.2e-6, inputPriority: 0.4e-6, output: 1.2e-6, outputPriority: 2.4e-6, cacheRead: 0.02e-6, cacheReadPriority: 0.04e-6}, } for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { @@ -136,8 +136,8 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi cacheWrite, output float64 }{ {name: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6}, - {name: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6}, - {name: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6}, + {name: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6}, + {name: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6}, } tiers := []struct { name string @@ -188,8 +188,8 @@ func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) { func TestPricingService_BareGPT56AliasDeterministicallyUsesSol(t *testing.T) { pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ "gpt-5.6-sol": {InputCostPerToken: 5e-6}, - "gpt-5.6-terra": {InputCostPerToken: 2.5e-6}, - "gpt-5.6-luna": {InputCostPerToken: 1e-6}, + "gpt-5.6-terra": {InputCostPerToken: 2e-6}, + "gpt-5.6-luna": {InputCostPerToken: 0.2e-6}, "gpt-5.4": {InputCostPerToken: 2.5e-6}, }} @@ -226,8 +226,8 @@ func TestDefaultPricingIncludesOfficialGPT56Rates(t *testing.T) { inputPriority, cachedPriority, cacheWritePriority, outputPriority float64 }{ {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6, inputPriority: 10e-6, cachedPriority: 1e-6, cacheWritePriority: 12.5e-6, outputPriority: 60e-6}, - {model: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6, inputPriority: 5e-6, cachedPriority: 0.5e-6, cacheWritePriority: 6.25e-6, outputPriority: 30e-6}, - {model: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6, inputPriority: 2e-6, cachedPriority: 0.2e-6, cacheWritePriority: 2.5e-6, outputPriority: 12e-6}, + {model: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6, inputPriority: 4e-6, cachedPriority: 0.4e-6, cacheWritePriority: 5e-6, outputPriority: 24e-6}, + {model: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6, inputPriority: 0.4e-6, cachedPriority: 0.04e-6, cacheWritePriority: 0.5e-6, outputPriority: 2.4e-6}, } for _, tt := range tests { t.Run(tt.model, func(t *testing.T) { @@ -254,8 +254,8 @@ func TestGPT56DedicatedFallbacksUseOfficialRates(t *testing.T) { input, cached, cacheWrite, output float64 }{ {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6}, - {model: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6}, - {model: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6}, + {model: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6}, + {model: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6}, } for _, tt := range tests { @@ -531,7 +531,7 @@ func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) { } } -func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) { +func TestDefaultPricingUsesCurrentCodexAutoReviewBaseRates(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) require.NoError(t, err) @@ -542,9 +542,19 @@ func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) { got := svc.GetModelPricing("codex-auto-review") require.NotNil(t, got) - require.InDelta(t, 5e-6, got.InputCostPerToken, 1e-12) - require.InDelta(t, 3e-5, got.OutputCostPerToken, 1e-12) - require.InDelta(t, 5e-7, got.CacheReadInputTokenCost, 1e-12) + require.InDelta(t, 0.2e-6, got.InputCostPerToken, 1e-12) + require.InDelta(t, 1.2e-6, got.OutputCostPerToken, 1e-12) + require.InDelta(t, 0.02e-6, got.CacheReadInputTokenCost, 1e-12) + + // Auto-review is an internal Codex model. Do not infer public GPT-5.6 API + // service-tier, cache-write, or long-context pricing without an upstream + // usage contract for this dedicated model. + require.Zero(t, got.InputCostPerTokenPriority) + require.Zero(t, got.OutputCostPerTokenPriority) + require.Zero(t, got.CacheReadInputTokenCostPriority) + require.Zero(t, got.CacheCreationInputTokenCost) + require.Zero(t, got.CacheCreationInputTokenCostPriority) + require.Zero(t, got.LongContextInputTokenThreshold) } func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) { diff --git a/backend/internal/service/profit_control_threshold_helpers_test.go b/backend/internal/service/profit_control_threshold_helpers_test.go new file mode 100644 index 0000000000..15f5bc1838 --- /dev/null +++ b/backend/internal/service/profit_control_threshold_helpers_test.go @@ -0,0 +1,38 @@ +package service + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestClampProfitControlThreshold 钉死阈值清洗口径:非有限或为负一律按 0 +// (等价于只放行免费上游),合法值原样保留。 +func TestClampProfitControlThreshold(t *testing.T) { + require.Equal(t, 0.0, clampProfitControlThreshold(math.NaN())) + require.Equal(t, 0.0, clampProfitControlThreshold(math.Inf(1))) + require.Equal(t, 0.0, clampProfitControlThreshold(math.Inf(-1))) + require.Equal(t, 0.0, clampProfitControlThreshold(-0.5)) + require.Equal(t, 0.0, clampProfitControlThreshold(0)) + require.Equal(t, 0.7, clampProfitControlThreshold(0.7)) +} + +// TestProfitControlOverThreshold 钉死「越线」的唯一定义:相对 epsilon 吸收 +// decimal(10,4) 落库与浮点乘法的边界误差,U == 阈值 判定为合格。 +// +// 线上否决点(openAIProfitControlVetoReason)与 profit-preview 现在共用这个 +// 函数与 clampProfitControlThreshold;此前各复制了一份,会随线上口径漂移, +// 导致预演结果与真实调度不一致。 +func TestProfitControlOverThreshold(t *testing.T) { + require.False(t, profitControlOverThreshold(0.7, 0.7), "U == 阈值 必须判定为合格") + require.False(t, profitControlOverThreshold(0.6999, 0.7)) + require.True(t, profitControlOverThreshold(0.7001, 0.7)) + + // 0.3*3 的浮点尾数不得被判成越线。 + require.False(t, profitControlOverThreshold(0.1*3, 0.3)) + + // 阈值为 0 时只放行 0 成本上游。 + require.False(t, profitControlOverThreshold(0, 0)) + require.True(t, profitControlOverThreshold(0.0001, 0)) +} diff --git a/backend/internal/service/profit_preview.go b/backend/internal/service/profit_preview.go new file mode 100644 index 0000000000..d63b9317ce --- /dev/null +++ b/backend/internal/service/profit_preview.go @@ -0,0 +1,211 @@ +package service + +// 分组利润控制离线预演:使用生产只读导出的分组配置、账号倍率、探测状态、 +// 用户覆盖倍率与主力模型清单,复用线上 U/D/epsilon 语义推演准入。 +// 探测状态仅用于解释账号倍率来源是否健康,不影响准入结论。 + +import ( + "math" + "sort" + "time" +) + +const ( + ProfitPreviewRateSourceManual = "manual" + ProfitPreviewRateSourceUpstreamProbe = "upstream_probe_sync" + + ProfitPreviewWarningProbeMissing = "probe_snapshot_missing" + ProfitPreviewWarningProbeStale = "probe_snapshot_stale" + ProfitPreviewWarningProbeFailed = "probe_failed" + ProfitPreviewWarningProbeUnsupported = "probe_unsupported" + ProfitPreviewWarningManualRateOne = "manual_rate_1_suspected_unmaintained" +) + +// ProfitPreviewGroupInput 是一个分组的预演输入。 +type ProfitPreviewGroupInput struct { + Group *Group + // Accounts 为该分组绑定且状态可调度的账号,允许 Anthropic/Gemini 分组中 + // 参与 mixed scheduling 的 Antigravity 账号。 + Accounts []*Account + // UserOverrides 为该分组所有用户覆盖倍率(user_id → 倍率)。 + UserOverrides map[int64]float64 + // Models 为该分组近期主力模型清单。 + Models []string + // AssumeEnabled 在分组当前关闭时仍按其保存配置启用利润门,供部署前执行 + // “先关组、预演、逐组重开”。 + AssumeEnabled bool +} + +// 预演账号分类与线上否决原因一一对应。 +const ( + ProfitPreviewClassAdmitted = "admitted" + ProfitPreviewClassRejectedThreshold = "rejected_threshold" + ProfitPreviewClassRejectedInvalidRate = "rejected_invalid_account_rate" +) + +// ProfitPreviewAccountVerdict 是单账号的预演结论。 +type ProfitPreviewAccountVerdict struct { + AccountID int64 `json:"account_id"` + Name string `json:"name"` + Platform string `json:"platform"` + Class string `json:"class"` + AccountRate *float64 `json:"account_rate,omitempty"` + RateSource string `json:"rate_source"` + Warnings []string `json:"warnings,omitempty"` + // RejectedUnderMinD 表示账号在最低有效 D(用户覆盖最坏口径)下会被排除。 + RejectedUnderMinD bool `json:"rejected_under_min_d,omitempty"` + // SupportedModels 为该账号支持的主力模型子集。 + SupportedModels []string `json:"supported_models,omitempty"` +} + +// ProfitPreviewGroupReport 是单分组的预演报告。 +type ProfitPreviewGroupReport struct { + GroupID int64 `json:"group_id"` + GroupName string `json:"group_name"` + Platform string `json:"platform"` + EffectiveGate bool `json:"effective_gate"` + AssumedEnabled bool `json:"assumed_enabled,omitempty"` + // DefaultD / MinEffectiveD:分组默认 D 与最低有效 D(含 evalAt 高峰因子; + // MinEffectiveD = min(分组默认, 全部非空用户覆盖) × 高峰因子)。 + DefaultD float64 `json:"default_d"` + MinEffectiveD float64 `json:"min_effective_d"` + // ThresholdDefault / ThresholdMinD:两档 D 对应的准入阈值。 + ThresholdDefault float64 `json:"threshold_default"` + ThresholdMinD float64 `json:"threshold_min_d"` + // RemainingByModel 仅表示利润门准入账号数,不模拟健康、冷却、限流或槽位。 + RemainingByModel map[string]int `json:"profit_admitted_by_model"` + RemainingByModelMinD map[string]int `json:"profit_admitted_by_model_min_d"` + Verdicts []ProfitPreviewAccountVerdict `json:"verdicts"` +} + +// PreviewProfitAdmission 对五大平台分组推演利润门准入结果。未启用的分组仅在 +// AssumeEnabled=true 时按保存配置执行门;Composite 分组始终不直接安装利润门。 +func PreviewProfitAdmission(inputs []ProfitPreviewGroupInput, evalAt time.Time) []ProfitPreviewGroupReport { + reports := make([]ProfitPreviewGroupReport, 0, len(inputs)) + for _, in := range inputs { + if in.Group == nil { + continue + } + group := in.Group + effectiveGate := (group.ProfitControlEnabled || in.AssumeEnabled) && profitControlPlatformSupported(group.Platform) + report := ProfitPreviewGroupReport{ + GroupID: group.ID, + GroupName: group.Name, + Platform: group.Platform, + EffectiveGate: effectiveGate, + AssumedEnabled: in.AssumeEnabled && !group.ProfitControlEnabled && effectiveGate, + RemainingByModel: make(map[string]int, len(in.Models)), + RemainingByModelMinD: make(map[string]int, len(in.Models)), + } + for _, model := range in.Models { + report.RemainingByModel[model] = 0 + report.RemainingByModelMinD[model] = 0 + } + + peak := group.PeakMultiplierAt(evalAt) + defaultD := group.RateMultiplier * peak + minRate := group.RateMultiplier + for _, override := range in.UserOverrides { + if math.IsNaN(override) || math.IsInf(override, 0) || override < 0 { + continue + } + if override < minRate { + minRate = override + } + } + minD := minRate * peak + deduction := group.ProfitMinMargin + group.ProfitSafetyBuffer + thresholdDefault := clampProfitControlThreshold(defaultD * (1 - deduction)) + thresholdMinD := clampProfitControlThreshold(minD * (1 - deduction)) + report.DefaultD = defaultD + report.MinEffectiveD = minD + report.ThresholdDefault = thresholdDefault + report.ThresholdMinD = thresholdMinD + + for _, account := range in.Accounts { + if account == nil { + continue + } + verdict := previewAccountProfitAdmission(account, effectiveGate, thresholdDefault, thresholdMinD, evalAt) + admittedDefault := verdict.Class == ProfitPreviewClassAdmitted + admittedMinD := admittedDefault && !verdict.RejectedUnderMinD + for _, model := range in.Models { + if !account.IsModelSupported(model) { + continue + } + verdict.SupportedModels = append(verdict.SupportedModels, model) + if admittedDefault { + report.RemainingByModel[model]++ + } + if admittedMinD { + report.RemainingByModelMinD[model]++ + } + } + sort.Strings(verdict.SupportedModels) + report.Verdicts = append(report.Verdicts, verdict) + } + sort.Slice(report.Verdicts, func(i, j int) bool { return report.Verdicts[i].AccountID < report.Verdicts[j].AccountID }) + reports = append(reports, report) + } + return reports +} + +func previewAccountProfitAdmission( + account *Account, + effectiveGate bool, + thresholdDefault float64, + thresholdMinD float64, + evalAt time.Time, +) ProfitPreviewAccountVerdict { + verdict := ProfitPreviewAccountVerdict{ + AccountID: account.ID, + Name: account.Name, + Platform: account.Platform, + RateSource: ProfitPreviewRateSourceManual, + } + if enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool); enabled { + verdict.RateSource = ProfitPreviewRateSourceUpstreamProbe + verdict.Warnings = append(verdict.Warnings, profitPreviewProbeWarnings(account, evalAt)...) + } else if account.RateMultiplier != nil && *account.RateMultiplier == 1 { + verdict.Warnings = append(verdict.Warnings, ProfitPreviewWarningManualRateOne) + } + + validRate := account.RateMultiplier != nil && + !math.IsNaN(*account.RateMultiplier) && + !math.IsInf(*account.RateMultiplier, 0) && + *account.RateMultiplier >= 0 + if validRate { + rate := *account.RateMultiplier + verdict.AccountRate = &rate + } + switch { + case !effectiveGate: + verdict.Class = ProfitPreviewClassAdmitted + case !validRate: + verdict.Class = ProfitPreviewClassRejectedInvalidRate + case profitControlOverThreshold(*account.RateMultiplier, thresholdDefault): + verdict.Class = ProfitPreviewClassRejectedThreshold + default: + verdict.Class = ProfitPreviewClassAdmitted + verdict.RejectedUnderMinD = profitControlOverThreshold(*account.RateMultiplier, thresholdMinD) + } + return verdict +} + +func profitPreviewProbeWarnings(account *Account, evalAt time.Time) []string { + snapshot := decodeUpstreamBillingProbeSnapshot(account.Extra) + if snapshot == nil { + return []string{ProfitPreviewWarningProbeMissing} + } + switch snapshot.Status { + case UpstreamBillingProbeStatusFailed: + return []string{ProfitPreviewWarningProbeFailed} + case UpstreamBillingProbeStatusUnsupported: + return []string{ProfitPreviewWarningProbeUnsupported} + case UpstreamBillingProbeStatusOK: + if snapshot.FreshUntil == nil || !evalAt.Before(*snapshot.FreshUntil) { + return []string{ProfitPreviewWarningProbeStale} + } + } + return nil +} diff --git a/backend/internal/service/profit_preview_test.go b/backend/internal/service/profit_preview_test.go new file mode 100644 index 0000000000..1030ef41aa --- /dev/null +++ b/backend/internal/service/profit_preview_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPreviewProfitAdmissionUsesAccountRatesAndPreinitializesModels(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(50, 0.2, 0) + group.Name = "VIP-preview" + + cheap := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1.0, now.Add(-3*time.Hour), 30*time.Minute), + 0.5, + ) + cheap.Name = "cheap" + cheap.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true + cheap.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-sol": "gpt-sol"}} + + boundary := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), + 0.8, + ) + boundary.Name = "boundary" + boundary.Credentials = map[string]any{"model_mapping": map[string]any{ + "gpt-sol": "gpt-sol", + "gpt-luna": "gpt-luna", + }} + + expensive := profitControlTestAccountWithRate( + upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 1.0, + ) + expensive.Name = "expensive" + + invalid := upstreamCostTestOAuthAccount(4) + invalid.Name = "invalid" + + reports := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheap, boundary, expensive, invalid}, + UserOverrides: map[int64]float64{40: 0.5}, + Models: []string{"gpt-sol", "gpt-luna", "gpt-no-account"}, + }}, now) + require.Len(t, reports, 1) + report := reports[0] + + require.True(t, report.EffectiveGate) + require.InDelta(t, 1.0, report.DefaultD, 1e-12) + require.InDelta(t, 0.8, report.ThresholdDefault, 1e-12) + require.InDelta(t, 0.5, report.MinEffectiveD, 1e-12) + require.InDelta(t, 0.4, report.ThresholdMinD, 1e-12) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range report.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheap.ID].Class) + require.Equal(t, ProfitPreviewRateSourceUpstreamProbe, byID[cheap.ID].RateSource) + require.Contains(t, byID[cheap.ID].Warnings, ProfitPreviewWarningProbeStale) + require.True(t, byID[cheap.ID].RejectedUnderMinD) + + require.Equal(t, ProfitPreviewClassAdmitted, byID[boundary.ID].Class, "U == 阈值按 epsilon 语义准入") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensive.ID].Class) + require.Contains(t, byID[expensive.ID].Warnings, ProfitPreviewWarningManualRateOne) + require.Equal(t, ProfitPreviewClassRejectedInvalidRate, byID[invalid.ID].Class) + + require.Equal(t, 2, report.RemainingByModel["gpt-sol"]) + require.Equal(t, 1, report.RemainingByModel["gpt-luna"]) + require.Equal(t, 0, report.RemainingByModel["gpt-no-account"]) + _, present := report.RemainingByModel["gpt-no-account"] + require.True(t, present, "全部账号均不支持时也必须显式保留 0,供 CLI 发出警告") +} + +func TestPreviewProfitAdmissionAssumeEnabled(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(51, 0, 0) + group.Platform = PlatformOpenAI + group.RateMultiplier = 0.5 + group.ProfitControlEnabled = false + + cheapAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute), + 0.2, + ) + expensiveAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 0.9, + ) + + withoutAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + }}, now)[0] + require.False(t, withoutAssume.EffectiveGate) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[0].Class) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[1].Class) + + withAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + AssumeEnabled: true, + }}, now)[0] + require.True(t, withAssume.EffectiveGate) + require.True(t, withAssume.AssumedEnabled) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range withAssume.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheapAccount.ID].Class, + "账号倍率 0.2 <= 阈值 0.5,探测快照的高倍率不参与准入判断") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensiveAccount.ID].Class, + "账号倍率 0.9 > 阈值 0.5,探测快照的低倍率不能替代账号倍率") +} + +func TestPreviewProfitAdmissionSupportsFivePlatforms(t *testing.T) { + for i, platform := range []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGemini, + PlatformGrok, + PlatformAntigravity, + } { + group := profitControlTestGroup(int64(100+i), 0, 0) + group.Platform = platform + rate := 0.2 + account := &Account{ + ID: int64(200 + i), + Platform: platform, + RateMultiplier: &rate, + } + report := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{account}, + Models: []string{"model"}, + }}, time.Now())[0] + require.True(t, report.EffectiveGate, platform) + require.Equal(t, ProfitPreviewClassAdmitted, report.Verdicts[0].Class, platform) + } +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 266f806e83..d6f4286be1 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -138,6 +138,97 @@ func (s *RateLimitService) notifyAccountSchedulingBlockCleared(accountID int64) s.runtimeBlocker.ClearAccountSchedulingBlock(accountID) } +// ApplyAccountSchedulingThreshold evaluates admin-configured per-platform +// utilization thresholds and, when breached, parks the account as temp- +// unschedulable until the winning window resets. Returns true when the account +// is blocked (either newly or already paused for the same threshold reason). +func (s *RateLimitService) ApplyAccountSchedulingThreshold(ctx context.Context, account *Account) bool { + if s == nil || s.settingService == nil || s.accountRepo == nil || account == nil || account.ID <= 0 { + return false + } + if !account.IsActive() || !account.Schedulable { + return false + } + + now := time.Now().UTC() + thresholds := s.settingService.GetAccountSchedulingThresholds(ctx) + decision := EvaluateAccountSchedulingThreshold(account, thresholds, now) + if !decision.ShouldPause || decision.Until == nil || !decision.Until.After(now) { + return false + } + + reason := BuildDetailedAccountSchedulingThresholdReason(AccountSchedulingThresholdReasonInput{ + Platform: decision.Platform, + Window: decision.Window, + Scope: decision.Scope, + ThresholdPercent: decision.ThresholdPercent, + UsedPercent: decision.UsedPercent, + Until: *decision.Until, + Now: now, + }) + + if accountHasSameSchedulingThresholdPause(account, *decision.Until, reason) { + return true + } + if !account.IsSchedulable() { + return false + } + + account.TempUnschedulableUntil = cloneTimePtr(decision.Until) + account.TempUnschedulableReason = reason + s.notifyAccountSchedulingBlocked(account, *decision.Until, "account_scheduling_threshold") + + if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, *decision.Until, reason); err != nil { + slog.Warn("account_scheduling_threshold_set_temp_unsched_failed", + "account_id", account.ID, + "platform", decision.Platform, + "window", decision.Window, + "scope", decision.Scope, + "threshold_percent", decision.ThresholdPercent, + "used_percent", decision.UsedPercent, + "until", decision.Until.UTC(), + "error", err) + } else if s.tempUnschedCache != nil { + if state := tempUnschedStateFromStoredReason(reason, decision.Until.Unix()); state != nil { + if err := s.tempUnschedCache.SetTempUnsched(ctx, account.ID, state); err != nil { + slog.Warn("account_scheduling_threshold_cache_set_failed", "account_id", account.ID, "error", err) + } + } + } + + slog.Info("account_scheduling_threshold_temp_unschedulable", + "account_id", account.ID, + "platform", decision.Platform, + "window", decision.Window, + "scope", decision.Scope, + "threshold_percent", decision.ThresholdPercent, + "used_percent", decision.UsedPercent, + "until", decision.Until.UTC()) + return true +} + +func accountHasSameSchedulingThresholdPause(account *Account, until time.Time, reason string) bool { + if account == nil || account.TempUnschedulableUntil == nil { + return false + } + if account.TempUnschedulableUntil.UTC().Unix() != until.UTC().Unix() { + return false + } + + existing, ok := parseTempUnschedReasonPayload(account.TempUnschedulableReason) + if !ok || existing.Source != AccountSchedulingThresholdReasonSource { + return false + } + next, ok := parseTempUnschedReasonPayload(reason) + if !ok || next.Source != AccountSchedulingThresholdReasonSource { + return false + } + + existing.TriggeredAtUnix = 0 + next.TriggeredAtUnix = 0 + return existing == next +} + // ErrorPolicyResult 表示错误策略检查的结果 type ErrorPolicyResult int @@ -2055,6 +2146,9 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco if modelKey == "" { return false } + if shouldSkipCodexPlanGatedImageModelCooldown(ctx, reason, requestedModel, modelKey) { + return true + } resetAt := time.Now().Add(cooldown) if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt, reason); err != nil { slog.Warn("upstream_model_not_found_set_model_rate_limit_failed", "account_id", account.ID, "model", modelKey, "reason", reason, "error", err) @@ -2064,6 +2158,29 @@ func (s *RateLimitService) HandleUpstreamModelNotFound(ctx context.Context, acco return true } +// shouldSkipCodexPlanGatedImageModelCooldown 判断这次 Codex plan-gated 400 是否 +// 属于"图片模型被文本端点拒绝"。 +// +// 这类错误是确定性的端点错配,不是账号能力缺失:同一账号通过 /v1/images/* 依然 +// 能出图。在这里写 per-model 冷却,会让一次用错端点的请求把整个号池对正确的生图 +// 端点下线(#4828)。 +// +// 但请求本身就从 /v1/images/* 入站时不适用——那种情况下被拒说明账号确实不具备 +// 该模型能力,冷却是必要的刹车:没有它,每个生图请求都会完整走一遍号池,对上游 +// 形成无上界的 400 放大。 +// +// 请求模型与最终冷却键都要判:冷却键走的是 account.GetMappedModel,账号可能把 +// 文本别名映射到 gpt-image-*,只判请求模型会漏掉这种形态。 +func shouldSkipCodexPlanGatedImageModelCooldown(ctx context.Context, reason, requestedModel, modelKey string) bool { + if reason != upstreamCodexPlanGatedModelReason { + return false + } + if OpenAIImagesEndpointFromContext(ctx) { + return false + } + return IsGPTImageGenerationModel(requestedModel) || IsGPTImageGenerationModel(modelKey) +} + func modelRateLimitKeyForUpstreamModelNotFound(ctx context.Context, account *Account, requestedModel string) string { modelKey := strings.TrimSpace(requestedModel) if account == nil || modelKey == "" { diff --git a/backend/internal/service/ratelimit_service_model_not_found_test.go b/backend/internal/service/ratelimit_service_model_not_found_test.go index 55becb28a5..5e2fa0f71d 100644 --- a/backend/internal/service/ratelimit_service_model_not_found_test.go +++ b/backend/internal/service/ratelimit_service_model_not_found_test.go @@ -382,6 +382,49 @@ func TestRateLimitService_HandleUpstreamError_CodexPlanGatedModelIgnoresAPIKeyAc require.Empty(t, repo.modelRateLimitCalls) } +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldown(t *testing.T) { + for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} { + t.Run(model, func(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The '`+model+`' model is not supported when using Codex with a ChatGPT account."}`), + model, + ) + + require.True(t, handled, "attempt should still fail over") + require.Empty(t, repo.modelRateLimitCalls, + "image models must not be cooled down: the account still serves them over /v1/images/*") + require.Zero(t, repo.tempCalls) + }) + } +} + +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedTextModelStillCoolsDown(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-5.6-sol' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-5.6-sol", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, "non-image plan-gated models keep the existing cooldown") + require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) +} + func openAICodexPlanGatedOAuthAccount() *Account { return &Account{ ID: 202, @@ -392,3 +435,93 @@ func openAICodexPlanGatedOAuthAccount() *Account { Credentials: map[string]any{}, } } + +// 请求本身就走 /v1/images/* 时必须保留冷却。 +// +// OAuth 账号的 /v1/images/* 上游同样是 Codex Responses(openai_images_responses.go +// → handleOpenAIImagesErrorResponse → handleOpenAIAccountUpstreamError → +// HandleUpstreamModelNotFound),所以这条路径也会命中 plan-gated 分支。账号确实 +// 不具备生图能力时,冷却是唯一的刹车:调度层靠 model_rate_limits 跳过该账号后 +// 快速 503;一旦跳过冷却,每个请求都会完整走一遍号池,对上游形成无上界的 400 放大。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelKeepsCooldownOnImagesEndpoint(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + WithOpenAIImagesEndpoint(context.Background()), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, + "/v1/images/* 上的 plan-gated 拒绝是真实的能力缺失,必须保留冷却刹车") + require.Equal(t, "gpt-image-2", repo.modelRateLimitCalls[0].scope) + require.Equal(t, upstreamCodexPlanGatedModelReason, repo.modelRateLimitCalls[0].reason) +} + +// 仅 WithOpenAIImageGenerationIntent(/v1/responses 因模型名自动置位)不算专用生图 +// 端点,仍按"用错端点"处理。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownOnIntentOnly(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + WithOpenAIImageGenerationIntent(context.Background()), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Empty(t, repo.modelRateLimitCalls) +} + +// 守卫口径必须与冷却键一致:冷却键走 account.GetMappedModel,账号可以把文本别名 +// 映射到 gpt-image-*,只判请求模型会漏掉这种形态,原 bug 原样复现。 +func TestRateLimitService_HandleUpstreamError_CodexPlanGatedImageModelSkipsCooldownViaModelMapping(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + account.Credentials["model_mapping"] = map[string]any{"my-draw-alias": "gpt-image-2"} + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + http.Header{}, + []byte(`{"detail":"The 'gpt-image-2' model is not supported when using Codex with a ChatGPT account."}`), + "my-draw-alias", + ) + + require.True(t, handled) + require.Empty(t, repo.modelRateLimitCalls, + "映射后的上游模型是图片模型,冷却键会写到 gpt-image-2 上,守卫必须一并识别") +} + +// 404 model-not-found 分支不受守卫影响:即使是图片模型也照常冷却。 +func TestRateLimitService_HandleUpstreamError_ModelNotFoundImageModelStillCoolsDown(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := openAICodexPlanGatedOAuthAccount() + + handled := svc.HandleUpstreamError( + context.Background(), + account, + http.StatusNotFound, + http.Header{}, + []byte(`{"error":{"message":"The model 'gpt-image-2' does not exist","code":"model_not_found"}}`), + "gpt-image-2", + ) + + require.True(t, handled) + require.Len(t, repo.modelRateLimitCalls, 1, "守卫只作用于 codex plan-gated 分支") + require.Equal(t, upstreamModelNotFoundReason, repo.modelRateLimitCalls[0].reason) +} diff --git a/backend/internal/service/ratelimit_service_scheduling_threshold_test.go b/backend/internal/service/ratelimit_service_scheduling_threshold_test.go new file mode 100644 index 0000000000..5069c4dec3 --- /dev/null +++ b/backend/internal/service/ratelimit_service_scheduling_threshold_test.go @@ -0,0 +1,166 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestRateLimitService_ApplyAccountSchedulingThreshold_SetsTempUnschedulable(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":80}` + + accountRepo := &rateLimitAccountRepoStub{} + rl := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rl.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + + until := time.Now().UTC().Add(6 * time.Hour) + account := &Account{ + ID: 1001, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 91.5, + "codex_7d_reset_at": until.Format(time.RFC3339), + }, + } + + blocked := rl.ApplyAccountSchedulingThreshold(context.Background(), account) + + require.True(t, blocked) + require.Equal(t, 1, accountRepo.tempCalls) + require.NotNil(t, account.TempUnschedulableUntil) + require.WithinDuration(t, until, *account.TempUnschedulableUntil, time.Second) + require.True(t, IsAccountSchedulingThresholdReason(accountRepo.lastTempReason)) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(accountRepo.lastTempReason), &payload)) + require.Equal(t, PlatformOpenAI, payload["platform"]) + require.Equal(t, "7d", payload["window"]) + require.Equal(t, float64(80), payload["threshold_percent"]) + require.Equal(t, float64(91.5), payload["used_percent"]) + require.Contains(t, payload["error_message"], "91.5% used >= 80%") +} + +func TestRateLimitService_ApplyAccountSchedulingThreshold_UsesAccountOverrideInReason(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":90}` + + accountRepo := &rateLimitAccountRepoStub{} + rl := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rl.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + + until := time.Now().UTC().Add(6 * time.Hour) + account := &Account{ + ID: 1003, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "account_scheduling_threshold": 80, + }, + Extra: map[string]any{ + "codex_7d_used_percent": 85.5, + "codex_7d_reset_at": until.Format(time.RFC3339), + }, + } + + blocked := rl.ApplyAccountSchedulingThreshold(context.Background(), account) + + require.True(t, blocked) + require.Equal(t, 1, accountRepo.tempCalls) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(accountRepo.lastTempReason), &payload)) + require.Equal(t, float64(80), payload["threshold_percent"]) + require.Equal(t, float64(85.5), payload["used_percent"]) + require.Contains(t, payload["error_message"], "85.5% used >= 80%") +} + +func TestRateLimitService_ApplyAccountSchedulingThreshold_SkipsDuplicateTempUnschedulable(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":80}` + + accountRepo := &rateLimitAccountRepoStub{} + rl := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rl.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + + until := time.Now().UTC().Add(6 * time.Hour).Truncate(time.Second) + existingReason := BuildDetailedAccountSchedulingThresholdReason(AccountSchedulingThresholdReasonInput{ + Platform: PlatformOpenAI, + Window: "7d", + ThresholdPercent: 80, + UsedPercent: 91.5, + Until: until, + Now: until.Add(-time.Hour), + }) + account := &Account{ + ID: 1002, + Platform: PlatformOpenAI, + Status: StatusActive, + Schedulable: true, + TempUnschedulableUntil: &until, + TempUnschedulableReason: existingReason, + Extra: map[string]any{ + "codex_7d_used_percent": 91.5, + "codex_7d_reset_at": until.Format(time.RFC3339), + }, + } + + blocked := rl.ApplyAccountSchedulingThreshold(context.Background(), account) + + require.True(t, blocked) + require.Equal(t, 0, accountRepo.tempCalls) + require.Equal(t, existingReason, account.TempUnschedulableReason) + require.NotNil(t, account.TempUnschedulableUntil) + require.True(t, until.Equal(*account.TempUnschedulableUntil)) +} + +func TestRateLimitService_ApplyAccountSchedulingThreshold_UnsupportedPlatformDoesNotBlock(t *testing.T) { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + + settingsRepo := newMockSettingRepo() + settingsRepo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":80}` + + accountRepo := &rateLimitAccountRepoStub{} + rl := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + rl.SetSettingService(NewSettingService(settingsRepo, &config.Config{})) + + account := &Account{ + ID: 2002, + Platform: PlatformKiro, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "account_scheduling_threshold": 1, + }, + Extra: map[string]any{ + "kiro_sched_utilization": 99.0, + "kiro_sched_reset_at": time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339), + }, + } + + blocked := rl.ApplyAccountSchedulingThreshold(context.Background(), account) + + require.False(t, blocked) + require.Equal(t, 0, accountRepo.tempCalls) + require.Nil(t, account.TempUnschedulableUntil) + require.Empty(t, account.TempUnschedulableReason) +} diff --git a/backend/internal/service/registration_email_policy.go b/backend/internal/service/registration_email_policy.go index 0c910d62cc..023ba6da90 100644 --- a/backend/internal/service/registration_email_policy.go +++ b/backend/internal/service/registration_email_policy.go @@ -5,6 +5,8 @@ import ( "fmt" "regexp" "strings" + + "golang.org/x/net/publicsuffix" ) var registrationEmailDomainPattern = regexp.MustCompile( @@ -20,6 +22,30 @@ func RegistrationEmailSuffix(email string) string { return "@" + domain } +// RegistrationEmailDomain 返回邮箱对应的可注册主域名,用于域名注册额度归一化。 +// 例如 abc.com 和 abcd.abc.com 都返回 abc.com;无法从公共后缀表归一化时保留原域名。 +func RegistrationEmailDomain(email string) string { + _, domain, ok := splitEmailForPolicy(email) + if !ok { + return "" + } + return NormalizeRegistrationEmailDomain(domain) +} + +// NormalizeRegistrationEmailDomain 将邮箱域名归一为可注册主域名。 +func NormalizeRegistrationEmailDomain(domain string) string { + domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "@"))) + domain = strings.TrimRight(domain, ".") + if domain == "" { + return "" + } + registrable, err := publicsuffix.EffectiveTLDPlusOne(domain) + if err != nil { + return domain + } + return registrable +} + // IsRegistrationEmailSuffixAllowed checks whether an email is allowed by suffix whitelist. // Empty whitelist means allow all. func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool { @@ -43,6 +69,11 @@ func IsRegistrationEmailSuffixAllowed(email string, whitelist []string) bool { return false } +// IsRegistrationEmailSuffixLimited 判断非空白名单是否对该邮箱域名启用单账户额度。 +func IsRegistrationEmailSuffixLimited(email string, whitelist []string) bool { + return len(whitelist) > 0 && !IsRegistrationEmailSuffixAllowed(email, whitelist) +} + // NormalizeRegistrationEmailSuffixWhitelist normalizes and validates suffix whitelist items. func NormalizeRegistrationEmailSuffixWhitelist(raw []string) ([]string, error) { return normalizeRegistrationEmailSuffixWhitelist(raw, true) @@ -146,5 +177,9 @@ func splitEmailForPolicy(raw string) (local string, domain string, ok bool) { if !found || local == "" || domain == "" || strings.Contains(domain, "@") { return "", "", false } + domain = strings.TrimRight(domain, ".") + if domain == "" { + return "", "", false + } return local, domain, true } diff --git a/backend/internal/service/registration_email_policy_test.go b/backend/internal/service/registration_email_policy_test.go index 79f47be01d..a3067b8379 100644 --- a/backend/internal/service/registration_email_policy_test.go +++ b/backend/internal/service/registration_email_policy_test.go @@ -3,6 +3,7 @@ package service import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -30,6 +31,7 @@ func TestParseRegistrationEmailSuffixWhitelist(t *testing.T) { func TestIsRegistrationEmailSuffixAllowed(t *testing.T) { require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com", []string{"@example.com"})) + require.True(t, IsRegistrationEmailSuffixAllowed("user@example.com.", []string{"@example.com"})) require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.example.com", []string{"@example.com"})) require.True(t, IsRegistrationEmailSuffixAllowed("user@qq.com", []string{"@qq.com"})) require.False(t, IsRegistrationEmailSuffixAllowed("user@sub.qq.com", []string{"@qq.com"})) @@ -42,3 +44,31 @@ func TestIsRegistrationEmailSuffixAllowed(t *testing.T) { require.False(t, IsRegistrationEmailSuffixAllowed("user@c.cn", []string{"@a.com", "*.b.cn"})) require.True(t, IsRegistrationEmailSuffixAllowed("user@any.com", []string{})) } + +func TestRegistrationEmailQuotaRejectsMalformedDomainWhenWhitelistConfigured(t *testing.T) { + repo := &userRepoStub{} + svc := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`, + SettingKeyRegistrationEmailDomainQuotaEnabled: "true", + }, nil, nil) + + _, _, err := svc.Register(context.Background(), "malformed-email", "password") + + require.ErrorIs(t, err, ErrEmailSuffixNotAllowed) + require.Empty(t, repo.created) +} + +func TestIsRegistrationEmailSuffixLimited(t *testing.T) { + require.False(t, IsRegistrationEmailSuffixLimited("user@custom.example", nil)) + require.False(t, IsRegistrationEmailSuffixLimited("user@example.com", []string{"@example.com"})) + require.True(t, IsRegistrationEmailSuffixLimited("user@custom.example", []string{"@example.com"})) +} + +func TestRegistrationEmailDomainUsesRegistrableDomain(t *testing.T) { + require.Equal(t, "abc.com", RegistrationEmailDomain("user@abc.com")) + require.Equal(t, "abc.com", RegistrationEmailDomain("user@abcd.abc.com")) + require.Equal(t, "example.co.uk", RegistrationEmailDomain("user@team.example.co.uk")) + require.Equal(t, "example.com", RegistrationEmailDomain("user@example.com.")) + require.Equal(t, "example.com", RegistrationEmailDomain("user@team.example.com.")) +} diff --git a/backend/internal/service/response_model_billing_test.go b/backend/internal/service/response_model_billing_test.go new file mode 100644 index 0000000000..9194a1684e --- /dev/null +++ b/backend/internal/service/response_model_billing_test.go @@ -0,0 +1,596 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 夹具模型必须同时满足两个条件,否则测的就不是想测的那条规则: +// 1. 两者价格不同——否则"更便宜才采纳"的断言退化成恒真; +// 2. 两者都能被 HasIdentifiedTokenPricing 确定性识别(即价格表里的精确条目), +// 否则请求会先被"响应模型必须可识别"这道更靠前的门挡掉,成本比较根本走不到。 +// +// claude-opus-4 / gpt-5.1 之类的名字不满足条件 2(前者不是 fallback 精确键, +// 后者与 gpt-5.5 共用同一条 gpt-5.4 价格因而也不满足条件 1)。 +const ( + anthropicCheapFixtureModel = "claude-sonnet-4" + anthropicPriceyFixtureModel = "claude-opus-4.8" + openAICheapFixtureModel = "gpt-5.4-nano" + openAIPriceyFixtureModel = "gpt-5.5" +) + +// orderedResponseBillingModels 返回 (cheaper, pricier) 及各自成本,按当前价格表排序, +// 使断言不依赖两个具体模型的价格大小关系(价格表调整时测试仍然自洽)。 +func orderedResponseBillingModels(t *testing.T, svc *BillingService, tokens UsageTokens, a, b string) (string, string, *CostBreakdown, *CostBreakdown) { + t.Helper() + costA, err := svc.CalculateCost(a, tokens, 1.1) + require.NoError(t, err) + costB, err := svc.CalculateCost(b, tokens, 1.1) + require.NoError(t, err) + require.NotEqual(t, costA.TotalCost, costB.TotalCost, "fixture prices for %s and %s must differ", a, b) + require.True(t, svc.HasIdentifiedTokenPricing(a), "fixture model %s must be identifiable in the pricing table", a) + require.True(t, svc.HasIdentifiedTokenPricing(b), "fixture model %s must be identifiable in the pricing table", b) + if costA.TotalCost < costB.TotalCost { + return a, b, costA, costB + } + return b, a, costB, costA +} + +// --- Anthropic gateway (GatewayService.RecordUsage) --- + +func TestGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_downgrade", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: cheaper, // upstream declared a runtime downgrade + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + // 审计链完整保留:请求/发送模型不因计费切换被改写,响应模型与 mismatch 记录在案。 + require.Equal(t, pricier, usageRepo.lastLog.Model) + require.Equal(t, pricier, usageRepo.lastLog.RequestedModel) + require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel) + require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel) + require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch) + require.True(t, *usageRepo.lastLog.UpstreamModelMismatch) +} + +func TestGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_forged_upgrade", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: cheaper, + UpstreamResponseModel: pricier, // forged/upgraded declaration must not raise cost + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: cheaper, + ChannelMappedModel: cheaper, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) { + tests := []struct { + name string + responseModel func(cheaper string) string + conflict bool + source string + }{ + { + name: "in_stream_conflict_falls_back_to_baseline", + responseModel: func(cheaper string) string { return cheaper }, + conflict: true, + source: BillingModelSourceResponse, + }, + { + name: "empty_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "" }, + source: BillingModelSourceResponse, + }, + { + name: "unpriced_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "zz-unpriced-response-model" }, + source: BillingModelSourceResponse, + }, + { + name: "default_channel_mapped_mode_ignores_response_model", + responseModel: func(cheaper string) string { return cheaper }, + source: BillingModelSourceChannelMapped, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_fallback_" + tt.name, + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: tt.responseModel(cheaper), + UpstreamResponseModelConflict: tt.conflict, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: tt.source, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12) + }) + } +} + +// --- OpenAI gateway (OpenAIGatewayService.RecordUsage) --- + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelBillsCheaperResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_downgrade", + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: cheaper, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + // 审计链完整保留。 + require.Equal(t, pricier, usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamResponseModel) + require.Equal(t, cheaper, *usageRepo.lastLog.UpstreamResponseModel) + require.NotNil(t, usageRepo.lastLog.UpstreamModelMismatch) + require.True(t, *usageRepo.lastLog.UpstreamModelMismatch) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsPricierResponseModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, cheaperCost, _ := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_forged_upgrade", + Model: cheaper, + UpstreamModel: cheaper, + UpstreamResponseModel: pricier, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: cheaper, + ChannelMappedModel: cheaper, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, cheaperCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, cheaperCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelSafeFallbacks(t *testing.T) { + tests := []struct { + name string + responseModel func(cheaper string) string + conflict bool + source string + }{ + { + name: "in_stream_conflict_falls_back_to_baseline", + responseModel: func(cheaper string) string { return cheaper }, + conflict: true, + source: BillingModelSourceResponse, + }, + { + name: "empty_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "" }, + source: BillingModelSourceResponse, + }, + { + name: "unpriced_response_model_falls_back_to_baseline", + responseModel: func(string) string { return "zz-unpriced-response-model" }, + source: BillingModelSourceResponse, + }, + { + name: "default_channel_mapped_mode_ignores_response_model", + responseModel: func(cheaper string) string { return cheaper }, + source: BillingModelSourceChannelMapped, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_fallback_" + tt.name, + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: tt.responseModel(cheaper), + UpstreamResponseModelConflict: tt.conflict, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: tt.source, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, pricierCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, pricierCost.ActualCost, userRepo.lastAmount, 1e-12) + }) + } +} + +// --- 准入规则本身 --- + +func TestResponseModelBillingDeclaration(t *testing.T) { + t.Parallel() + tests := []struct { + name string + source string + model string + conflict bool + mediaBilled bool + want string + }{ + {name: "opted_in_and_clean", source: BillingModelSourceResponse, model: " claude-sonnet-4 ", want: "claude-sonnet-4"}, + {name: "other_source_never_looks_at_response", source: BillingModelSourceChannelMapped, model: "claude-sonnet-4"}, + {name: "empty_source_never_looks_at_response", source: "", model: "claude-sonnet-4"}, + {name: "upstream_source_never_looks_at_response", source: BillingModelSourceUpstream, model: "claude-sonnet-4"}, + {name: "in_stream_conflict_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", conflict: true}, + {name: "media_billed_request_rejected", source: BillingModelSourceResponse, model: "claude-sonnet-4", mediaBilled: true}, + {name: "blank_declaration_rejected", source: BillingModelSourceResponse, model: " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, responseModelBillingDeclaration(tt.source, tt.model, tt.conflict, tt.mediaBilled)) + }) + } +} + +// 上游自报的模型名是外部输入。GetModelPricing 的系列兜底会给任意含 "haiku" 的名字 +// 返回最便宜的系列价,因此计费准入必须走"确定性识别",否则上游随手编一个名字就能 +// 把账单压到地板价。本用例把这个差异钉死。 +func TestBillingServiceHasIdentifiedTokenPricing_RejectsFamilyGuesses(t *testing.T) { + t.Parallel() + billing := newGatewayRecordUsageServiceForTest( + &openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, + ).billingService + + require.True(t, billing.HasIdentifiedTokenPricing("claude-sonnet-4")) + require.True(t, billing.HasIdentifiedTokenPricing(" CLAUDE-SONNET-4 "), "识别应当忽略大小写与空白") + require.True(t, billing.HasIdentifiedTokenPricing("gpt-5.4-nano")) + + const forged = "totally-made-up-haiku-v9" + if _, err := billing.GetModelPricing(forged); err == nil { + // 这正是本函数存在的理由:宽松查价对编造的名字也会成功。 + require.False(t, billing.HasIdentifiedTokenPricing(forged), + "family-guessed pricing must not qualify a model as a billing basis") + } + require.False(t, billing.HasIdentifiedTokenPricing("")) + require.False(t, billing.HasIdentifiedTokenPricing("zz-unpriced-response-model")) +} + +func TestGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + const forged = "totally-made-up-haiku-v9" + + baselineCost, err := svc.billingService.CalculateCost(anthropicPriceyFixtureModel, tokens, 1.1) + require.NoError(t, err) + // 前提:这个编造的名字确实能被宽松查价算出更低的费用——正是必须被拒绝的那条路径。 + forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1) + require.NoError(t, err) + require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost) + + err = svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_forged_family_name", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: anthropicPriceyFixtureModel, + UpstreamResponseModel: forged, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: anthropicPriceyFixtureModel, + ChannelMappedModel: anthropicPriceyFixtureModel, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + const forged = "totally-made-up-haiku-v9" + + baselineCost, err := svc.billingService.CalculateCost(openAIPriceyFixtureModel, tokens, 1.1) + require.NoError(t, err) + forgedCost, err := svc.billingService.CalculateCost(forged, tokens, 1.1) + require.NoError(t, err) + require.Less(t, forgedCost.TotalCost, baselineCost.TotalCost) + + err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_forged_family_name", + Model: openAIPriceyFixtureModel, + UpstreamModel: openAIPriceyFixtureModel, + UpstreamResponseModel: forged, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: openAIPriceyFixtureModel, + ChannelMappedModel: openAIPriceyFixtureModel, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, baselineCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, baselineCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +// --- 成本准入的三条不变式 --- + +func TestResponseModelBillingAdoptable(t *testing.T) { + t.Parallel() + cost := func(total float64) *CostBreakdown { + return &CostBreakdown{TotalCost: total, ActualCost: total} + } + tests := []struct { + name string + baseline *CostBreakdown + response *CostBreakdown + baselineChannelPriced bool + responseChannelPriced bool + want bool + }{ + // 1. 不得更贵 + {name: "cheaper_adopted", baseline: cost(1), response: cost(0.5), want: true}, + {name: "equal_adopted", baseline: cost(1), response: cost(1), want: true}, + {name: "float_noise_within_epsilon_adopted", baseline: cost(1), response: cost(1 + 1e-13), want: true}, + {name: "pricier_rejected", baseline: cost(1), response: cost(1.0001)}, + + // 2. 不得把一笔本应计费的请求归零(价格表里有显式写 0 的条目,能通过确定性识别) + {name: "zeroing_a_billable_request_rejected", baseline: cost(1), response: cost(0)}, + {name: "negative_cost_rejected_as_zeroing", baseline: cost(1), response: cost(-1)}, + {name: "already_zero_baseline_unaffected", baseline: cost(0), response: cost(0), want: true}, + + // 3. 不得从渠道定价跨到全局价格表(否则渠道加价被带日期的自报模型名绕过) + {name: "channel_priced_baseline_to_global_rejected", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true}, + {name: "channel_priced_on_both_sides_adopted", baseline: cost(1), response: cost(0.5), baselineChannelPriced: true, responseChannelPriced: true, want: true}, + {name: "global_baseline_to_channel_priced_adopted", baseline: cost(1), response: cost(0.5), responseChannelPriced: true, want: true}, + + {name: "nil_baseline_rejected", response: cost(0.5)}, + {name: "nil_response_rejected", baseline: cost(1)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, responseModelBillingAdoptable( + tt.baseline, tt.response, tt.baselineChannelPriced, tt.responseChannelPriced, + )) + }) + } +} + +// --- 按次/按量计费请求一律不采纳(门的调用点接线) --- +// +// 搜索附加费是叠加在 token 成本之上的,所以"采纳与否"会体现在最终金额上,本用例因此 +// 能真正区分两条分支。语音(AudioUsage)走的是与模型无关的按量单价,采纳与否金额相同, +// 无法用金额断言区分,故只由 TestResponseModelBillingDeclaration 覆盖门本身。 + +func TestGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, anthropicCheapFixtureModel, anthropicPriceyFixtureModel) + + const searchCalls = 2 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_response_model_search_surcharge", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ResponseModelSkippedForSearchSurchargedRequest(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + tokens := UsageTokens{InputTokens: 20, OutputTokens: 10} + cheaper, pricier, _, pricierCost := orderedResponseBillingModels(t, svc.billingService, tokens, openAICheapFixtureModel, openAIPriceyFixtureModel) + + const searchCalls = 3 + searchCost := svc.billingService.CalculateSearchCost(searchCalls, nil, 1.1) + require.NotNil(t, searchCost) + require.Greater(t, searchCost.ActualCost, 0.0, "夹具附加费必须非零,否则断言分不出两条分支") + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_response_model_search_surcharge", + Model: pricier, + UpstreamModel: pricier, + UpstreamResponseModel: cheaper, + SearchCount: searchCalls, + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + ChannelID: 9, + OriginalModel: pricier, + ChannelMappedModel: pricier, + BillingModelSource: BillingModelSourceResponse, + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + want := pricierCost.ActualCost + searchCost.ActualCost + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, want, userRepo.lastAmount, 1e-12) +} + +// --- 渠道配置透传 --- + +func TestToUsageFields_ResponseModelSourcePassesThrough(t *testing.T) { + r := ChannelMappingResult{ + MappedModel: "claude-fable-5", + ChannelID: 4, + Mapped: false, + BillingModelSource: BillingModelSourceResponse, + } + fields := r.ToUsageFields("claude-fable-5", "claude-fable-5") + require.Equal(t, int64(4), fields.ChannelID) + require.Equal(t, BillingModelSourceResponse, fields.BillingModelSource) +} diff --git a/backend/internal/service/scheduler_snapshot_cancellation_test.go b/backend/internal/service/scheduler_snapshot_cancellation_test.go new file mode 100644 index 0000000000..3f46cb14aa --- /dev/null +++ b/backend/internal/service/scheduler_snapshot_cancellation_test.go @@ -0,0 +1,79 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type schedulerCancellationCache struct { + SchedulerCache + cancel context.CancelFunc + tokenCaptures int +} + +func (c *schedulerCancellationCache) GetSnapshot(ctx context.Context, _ SchedulerBucket) ([]*Account, bool, error) { + c.cancel() + return nil, false, ctx.Err() +} + +func (c *schedulerCancellationCache) CaptureBucketWriteToken(ctx context.Context, _ SchedulerBucket) (SchedulerBucketWriteToken, error) { + c.tokenCaptures++ + return SchedulerBucketWriteToken{}, ctx.Err() +} + +func (c *schedulerCancellationCache) GetAccount(ctx context.Context, _ int64) (*Account, error) { + c.cancel() + return nil, ctx.Err() +} + +type schedulerCancellationAccountRepo struct { + AccountRepository + listCalls int + getByIDCalls int +} + +func (r *schedulerCancellationAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Context, _ string) ([]Account, error) { + r.listCalls++ + return nil, ctx.Err() +} + +func (r *schedulerCancellationAccountRepo) GetByID(ctx context.Context, _ int64) (*Account, error) { + r.getByIDCalls++ + return nil, ctx.Err() +} + +func TestSchedulerSnapshotListStopsAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cache := &schedulerCancellationCache{cancel: cancel} + repo := &schedulerCancellationAccountRepo{} + svc := NewSchedulerSnapshotService(cache, nil, repo, nil, nil) + + accounts, useMixed, err := svc.ListSchedulableAccounts(ctx, nil, PlatformOpenAI, false) + + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, accounts) + require.False(t, useMixed) + require.Zero(t, cache.tokenCaptures, "canceled requests must not capture a cache publish token") + require.Zero(t, repo.listCalls, "canceled requests must not fall back to the database") +} + +func TestSchedulerSnapshotGetAccountStopsAfterRequestCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cache := &schedulerCancellationCache{cancel: cancel} + repo := &schedulerCancellationAccountRepo{} + svc := NewSchedulerSnapshotService(cache, nil, repo, nil, nil) + + account, err := svc.GetAccount(ctx, 42) + + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, account) + require.Zero(t, repo.getByIDCalls, "canceled requests must not fall back to the database") +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 38451b9b8e..9655f54f25 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -213,15 +213,24 @@ func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, bucket := s.bucketFor(groupID, platform, mode) var writeToken SchedulerBucketWriteToken canPublish := false + if err := ctx.Err(); err != nil { + return nil, useMixed, err + } if s.cache != nil { cached, hit, err := s.cache.GetSnapshot(ctx, bucket) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if err != nil { logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] cache read failed: bucket=%s err=%v", bucket.String(), err) } else if hit { return derefAccounts(cached), useMixed, nil } token, err := s.cache.CaptureBucketWriteToken(ctx, bucket) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if err != nil { if errors.Is(err, ErrSchedulerBucketRetired) || errors.Is(err, ErrSchedulerBucketWriteFenced) { slog.Debug("[Scheduler] cache publish fenced", "bucket", bucket.String()) @@ -245,6 +254,9 @@ func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, if err != nil { return nil, useMixed, err } + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, useMixed, ctxErr + } if s.cache != nil && canPublish { if err := s.cache.SetSnapshot(fallbackCtx, bucket, writeToken, accounts); err != nil { @@ -263,8 +275,14 @@ func (s *SchedulerSnapshotService) GetAccount(ctx context.Context, accountID int if accountID <= 0 { return nil, nil } + if err := ctx.Err(); err != nil { + return nil, err + } if s.cache != nil { account, err := s.cache.GetAccount(ctx, accountID) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } if err != nil { logger.LegacyPrintf("service.scheduler_snapshot", "[Scheduler] account cache read failed: id=%d err=%v", accountID, err) } else if account != nil { @@ -288,6 +306,17 @@ func (s *SchedulerSnapshotService) GetGroupByID(ctx context.Context, groupID int return s.groupRepo.GetByID(ctx, groupID) } +// GetGroupByIDLite 获取分组配置但不加载账号计数聚合。 +// 利润门只需要平台、倍率、利润与高峰字段,GetByID 附带的那条账号计数聚合 +// 查询纯属浪费——composite / 模型路由 / fallback 每次装门都要付一次,WS 更是 +// 每个 turn 一次,且发生在「是否启用利润控制」判定之前。 +func (s *SchedulerSnapshotService) GetGroupByIDLite(ctx context.Context, groupID int64) (*Group, error) { + if s.groupRepo == nil { + return nil, nil + } + return s.groupRepo.GetByIDLite(ctx, groupID) +} + // UpdateAccountInCache 立即更新 Redis 中单个账号的数据(用于模型限流后立即生效) func (s *SchedulerSnapshotService) UpdateAccountInCache(ctx context.Context, account *Account) error { if s.cache == nil || account == nil { diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 50a3fbcfd8..648fa1dc21 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -11,6 +11,7 @@ import ( "math" "strconv" "strings" + "time" ) // IsRegistrationEnabled 检查是否开放注册 @@ -32,6 +33,16 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool { return value == "true" } +// IsRegistrationEmailDomainQuotaEnabled 检查白名单非空时是否放行非白名单域名限量注册。 +// 安全默认:设置缺失或查询出错时按关闭处理(保持白名单严格模式)。 +func (s *SettingService) IsRegistrationEmailDomainQuotaEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailDomainQuotaEnabled) + if err != nil { + return false + } + return value == "true" +} + // GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist. func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string { value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist) @@ -455,6 +466,87 @@ func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string { return value } +// TencentCaptchaConfig contains the credentials required by Tencent Cloud's +// ticket verification API. It must never be returned by a public handler. +type TencentCaptchaConfig struct { + Enabled bool + AppID string + AppSecretKey string + CloudSecretID string + CloudSecretKey string + Region string +} + +// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's +// server-side verification API. It must never be returned by a public handler. +type AliyunCaptchaConfig struct { + Enabled bool + AccessKeyID string + AccessKeySecret string + SceneID string + Region string +} + +type CaptchaProviderConfig struct { + TurnstileEnabled bool + TurnstileSecretKey string + Tencent TencentCaptchaConfig + Aliyun AliyunCaptchaConfig +} + +func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) { + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyTurnstileEnabled, + SettingKeyTurnstileSecretKey, + SettingKeyTencentCaptchaEnabled, + SettingKeyTencentCaptchaAppID, + SettingKeyTencentCaptchaAppSecretKey, + SettingKeyTencentCaptchaCloudSecretID, + SettingKeyTencentCaptchaCloudSecretKey, + SettingKeyTencentCaptchaRegion, + SettingKeyAliyunCaptchaEnabled, + SettingKeyAliyunCaptchaAccessKeyID, + SettingKeyAliyunCaptchaAccessKeySecret, + SettingKeyAliyunCaptchaSceneID, + SettingKeyAliyunCaptchaRegion, + }) + if err != nil { + return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err) + } + return CaptchaProviderConfig{ + TurnstileEnabled: values[SettingKeyTurnstileEnabled] == "true", + TurnstileSecretKey: values[SettingKeyTurnstileSecretKey], + Tencent: TencentCaptchaConfig{ + Enabled: values[SettingKeyTencentCaptchaEnabled] == "true", + AppID: values[SettingKeyTencentCaptchaAppID], + AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey], + CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID], + CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey], + Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]), + }, + Aliyun: AliyunCaptchaConfig{ + Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true", + AccessKeyID: values[SettingKeyAliyunCaptchaAccessKeyID], + AccessKeySecret: values[SettingKeyAliyunCaptchaAccessKeySecret], + SceneID: values[SettingKeyAliyunCaptchaSceneID], + Region: normalizeAliyunCaptchaRegion(values[SettingKeyAliyunCaptchaRegion]), + }, + }, nil +} + +func (s *SettingService) IsTencentCaptchaEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTencentCaptchaEnabled) + return err == nil && value == "true" +} + +func (s *SettingService) GetTencentCaptchaConfig(ctx context.Context) TencentCaptchaConfig { + config, err := s.GetCaptchaProviderConfig(ctx) + if err != nil { + return TencentCaptchaConfig{} + } + return config.Tencent +} + // IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入) func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch) @@ -997,6 +1089,69 @@ func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[stri return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 } +// GetAccountSchedulingThresholds returns per-platform auto-pause thresholds (1..100). +// 100 disables the threshold for that platform. Hot-path cached with singleflight. +func (s *SettingService) GetAccountSchedulingThresholds(ctx context.Context) map[string]int { + if s == nil || s.settingRepo == nil { + return defaultAccountSchedulingThresholds() + } + if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok { + if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt { + return cloneAccountSchedulingThresholds(cached.thresholds) + } + } + + result, err, _ := accountSchedulingThresholdsSF.Do(SettingKeyAccountSchedulingThresholds, func() (any, error) { + if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok { + if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt { + return cloneAccountSchedulingThresholds(cached.thresholds), nil + } + } + + thresholds := defaultAccountSchedulingThresholds() + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), accountSchedulingThresholdsDBTimeout) + defer cancel() + + raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyAccountSchedulingThresholds) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{ + thresholds: cloneAccountSchedulingThresholds(thresholds), + expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(), + }) + return cloneAccountSchedulingThresholds(thresholds), nil + } + slog.Warn("failed to get account scheduling thresholds, falling back to defaults", "error", err) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{ + thresholds: cloneAccountSchedulingThresholds(thresholds), + expiresAt: time.Now().Add(accountSchedulingThresholdsErrorTTL).UnixNano(), + }) + return cloneAccountSchedulingThresholds(thresholds), nil + } + + if trimmed := strings.TrimSpace(raw); trimmed != "" { + if parsed, err := parseAccountSchedulingThresholdsSetting(trimmed); err != nil { + slog.Warn("failed to parse account scheduling thresholds, falling back to defaults", "error", err) + } else { + thresholds = parsed + } + } + + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{ + thresholds: cloneAccountSchedulingThresholds(thresholds), + expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(), + }) + return cloneAccountSchedulingThresholds(thresholds), nil + }) + if err != nil { + return defaultAccountSchedulingThresholds() + } + if thresholds, ok := result.(map[string]int); ok { + return cloneAccountSchedulingThresholds(thresholds) + } + return defaultAccountSchedulingThresholds() +} + // GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting { out := map[string]*DefaultPlatformQuotaSetting{} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 3ed4f39def..2f13278e3b 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -72,6 +72,19 @@ const gatewayForwardingCacheTTL = 60 * time.Second const gatewayForwardingErrorTTL = 5 * time.Second const gatewayForwardingDBTimeout = 5 * time.Second +// cachedAccountSchedulingThresholds 缓存平台自动停调阈值(进程内缓存,60s TTL) +type cachedAccountSchedulingThresholds struct { + thresholds map[string]int + expiresAt int64 // unix nano +} + +var accountSchedulingThresholdsCache atomic.Value // *cachedAccountSchedulingThresholds +var accountSchedulingThresholdsSF singleflight.Group + +const accountSchedulingThresholdsCacheTTL = 60 * time.Second +const accountSchedulingThresholdsErrorTTL = 5 * time.Second +const accountSchedulingThresholdsDBTimeout = 5 * time.Second + // cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL) type cachedAntigravityUserAgentVersion struct { version string @@ -82,8 +95,9 @@ const antigravityUserAgentVersionCacheTTL = 60 * time.Second const antigravityUserAgentVersionErrorTTL = 5 * time.Second const antigravityUserAgentVersionDBTimeout = 5 * time.Second -// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询) -const DefaultOpenAICodexUserAgent = "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)" +// DefaultOpenAICodexUserAgent 是 OpenAI Codex 默认 User-Agent,用于规避浏览器 UA 的质询。 +// 默认采用 codex-tui 身份,版本段随 codexCLIVersion 一起更新。 +const DefaultOpenAICodexUserAgent = codexCLIUserAgent // cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL) type cachedOpenAICodexUserAgent struct { @@ -91,6 +105,19 @@ type cachedOpenAICodexUserAgent struct { expiresAt int64 // unix nano } +// cachedOpenAICodexClientVersion 缓存出站 Codex 客户端版本号(进程内缓存,60s TTL) +type cachedOpenAICodexClientVersion struct { + version string + expiresAt int64 // unix nano +} + +const openAICodexClientVersionCacheTTL = 60 * time.Second +const openAICodexClientVersionErrorTTL = 5 * time.Second +const openAICodexClientVersionDBTimeout = 5 * time.Second + +// openAICodexClientVersionSFKey singleflight 键。 +const openAICodexClientVersionSFKey = "openai_codex_client_version" + type cachedOpenAIQuotaAutoPauseSettings struct { settings OpsOpenAIAccountQuotaAutoPauseSettings expiresAt int64 @@ -282,6 +309,97 @@ func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { return fallback } +// GetOpenAICodexClientVersion 返回出站声明的 Codex 客户端版本号。 +// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新稳定版 → 内置常量。 +// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布; +// 自动同步让运维不必为了跟版本而发新版本。 +func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string { + fallback := codexCLIVersion + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.openAICodexVersionSF.Do(openAICodexClientVersionSFKey, func() (any, error) { + if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexClientVersionDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyOpenAICodexClientVersion, + SettingKeyOpenAICodexClientVersionSynced, + }) + if err != nil { + slog.Warn("failed to get openai codex client version setting", "error", err) + s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{ + version: fallback, + expiresAt: time.Now().Add(openAICodexClientVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion]) + if version == "" { + version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced]) + } + if version == "" { + version = fallback + } + s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{ + version: version, + expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// InvalidateOpenAICodexClientVersionCache 丢弃版本号缓存,下次读取回源。 +// 面板保存与自动同步写入后调用。 +func (s *SettingService) InvalidateOpenAICodexClientVersionCache() { + if s == nil { + return + } + s.openAICodexVersionSF.Forget(openAICodexClientVersionSFKey) + s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil)) +} + +// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。 +// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。 +// +// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是 +// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧 +// 版本上并绕过自动同步——而陈旧身份正是上游优先降载的那一侧。 +// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 +func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string { + if s == nil { + return codexCLIUserAgent + } + version := s.GetOpenAICodexClientVersion(ctx) + ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx)) + if ua == "" { + return buildCodexCLIUserAgent(version) + } + if rebuilt := openai.SetCodexUserAgentVersion(ua, version); rebuilt != "" { + return rebuilt + } + // 非 `{client}/{version}` 形态:交给 PairCodexClientIdentity 判定, + // 推导不出官方身份时由收口整体回退规范身份。 + return ua +} + var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ Originator: "Claude Code", UAContains: []string{"Claude Code/"}, diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index a4d409f9c1..283c76f8f4 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) // InitializeDefaultSettings 初始化默认设置 @@ -57,6 +58,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "false", SettingKeyRegistrationEmailSuffixWhitelist: "[]", + SettingKeyRegistrationEmailDomainQuotaEnabled: "false", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyLoginAgreementEnabled: "false", SettingKeyLoginAgreementMode: defaultLoginAgreementMode, @@ -185,7 +187,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Channel monitor defaults (enabled, 60s) SettingKeyChannelMonitorEnabled: "true", + SettingKeyChannelMonitorMode: ChannelMonitorModeV1, SettingKeyChannelMonitorDefaultIntervalSeconds: "60", + SettingKeyChannelMonitorHideThroughput: "true", + + // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. + SettingKeyGrokDefaultTextModel: "grok-4.5", + SettingKeyGrokCrossClientModelMapEnabled: "true", + SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, // Available channels feature (default disabled; opt-in) SettingKeyAvailableChannelsEnabled: "false", @@ -227,6 +236,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyEnableClientDatelineNormalization: "true", SettingKeyAntigravityUserAgentVersion: "", SettingKeyOpenAICodexUserAgent: "", + SettingKeyOpenAICodexClientVersion: "", + SettingKeyOpenAICodexClientVersionSynced: "", + SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -296,46 +308,60 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } } result := &SystemSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", - FrontendURL: settings[SettingKeyFrontendURL], - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.passkeySettingEnabled(settings), - SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 - StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 - AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: settings[SettingKeySMTPHost], - SMTPUsername: settings[SettingKeySMTPUsername], - SMTPFrom: settings[SettingKeySMTPFrom], - SMTPFromName: settings[SettingKeySMTPFromName], - SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", - SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", - APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, - ForwardedClientIPHeaders: forwardedClientIPHeaders, - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), + RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + FrontendURL: settings[SettingKeyFrontendURL], + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeySettingEnabled(settings), + SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] == "true", // 默认关闭 + StepUpEnabled: settings[SettingKeyStepUpEnabled] == "true", // 默认关闭 + AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]), + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: settings[SettingKeySMTPHost], + SMTPUsername: settings[SettingKeySMTPUsername], + SMTPFrom: settings[SettingKeySMTPFrom], + SMTPFromName: settings[SettingKeySMTPFromName], + SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true", + SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "", + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", + TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", + TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], + TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "", + TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "", + TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "", + TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), + AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", + AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID], + AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "", + AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], + AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], + AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), + APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, + ForwardedClientIPHeaders: forwardedClientIPHeaders, + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", } result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences( settings[SettingKeyTableDefaultPageSize], @@ -391,6 +417,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 敏感信息直接返回,方便测试连接时使用 result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] + result.TencentCaptchaAppSecretKey = settings[SettingKeyTencentCaptchaAppSecretKey] + result.TencentCaptchaCloudSecretID = settings[SettingKeyTencentCaptchaCloudSecretID] + result.TencentCaptchaCloudSecretKey = settings[SettingKeyTencentCaptchaCloudSecretKey] + result.AliyunCaptchaAccessKeySecret = settings[SettingKeyAliyunCaptchaAccessKeySecret] // LinuxDo Connect 设置: // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) @@ -761,9 +791,23 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Channel monitor feature (default: enabled, 60s) result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) + result.ChannelMonitorMode = normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]) result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( settings[SettingKeyChannelMonitorDefaultIntervalSeconds], ) + // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 + // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 + result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) + + // Grok default mapping policy + result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) + if result.GrokDefaultTextModel == "" { + result.GrokDefaultTextModel = "grok-4.5" + } + // Default true (missing/empty → enabled) so Claude/Codex→Grok mapping keeps working. + // Operators can set false to disable silent cross-client rewrite. + result.GrokCrossClientModelMapEnabled = !isFalseSettingValue(settings[SettingKeyGrokCrossClientModelMapEnabled]) + result.GrokDefaultBaseURLMode = normalizeGrokDefaultBaseURLMode(settings[SettingKeyGrokDefaultBaseURLMode]) // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" @@ -823,6 +867,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) + result.OpenAICodexClientVersion = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersion]) + result.OpenAICodexClientVersionSynced = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersionSynced]) + // 自动同步默认开启:缺失/空值一律视为开启,与 enable_client_dateline_normalization 同一惯例。 + if v, ok := settings[SettingKeyOpenAICodexVersionAutoSyncEnabled]; ok && v != "" { + result.OpenAICodexVersionAutoSyncEnabled = v == "true" + } else { + result.OpenAICodexVersionAutoSyncEnabled = true + } // codex_cli_only 加固 result.MinCodexVersion = settings[SettingKeyMinCodexVersion] result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] @@ -901,9 +953,23 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.DefaultPlatformQuotas = parsed } } + result.AccountSchedulingThresholds = defaultAccountSchedulingThresholds() + if raw := strings.TrimSpace(settings[SettingKeyAccountSchedulingThresholds]); raw != "" { + if thresholds, err := parseAccountSchedulingThresholdsSetting(raw); err != nil { + slog.Warn("[Setting] parseSettings: unmarshal account_scheduling_thresholds failed", "error", err) + } else { + result.AccountSchedulingThresholds = thresholds + } + } result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false + // Publish Grok default model_mapping options for accounts with empty mapping. + xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{ + DefaultText: result.GrokDefaultTextModel, + EnableCrossClientMap: result.GrokCrossClientModelMapEnabled, + }) + return result } diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 76af91c305..f292e8ac57 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -160,6 +160,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyEmailVerifyEnabled, SettingKeyForceEmailOnThirdPartySignup, SettingKeyRegistrationEmailSuffixWhitelist, + SettingKeyRegistrationEmailDomainQuotaEnabled, SettingKeyPromoCodeEnabled, SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, @@ -171,6 +172,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyLoginAgreementDocuments, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, + SettingKeyTencentCaptchaEnabled, + SettingKeyTencentCaptchaAppID, + SettingKeyTencentCaptchaRegion, + SettingKeyAliyunCaptchaEnabled, + SettingKeyAliyunCaptchaSceneID, + SettingKeyAliyunCaptchaPrefix, + SettingKeyAliyunCaptchaRegion, SettingKeyAPIKeyACLTrustForwardedIP, SettingKeySiteName, SettingKeySiteLogo, @@ -179,6 +187,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyContactInfo, SettingKeyDocURL, SettingKeyHomeContent, + SettingKeyCompactHomeEnabled, SettingKeyHideCcsImportButton, SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionURL, @@ -219,7 +228,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyChannelMonitorHideThroughput, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, @@ -284,55 +295,66 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: emailVerifyEnabled, - ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", - RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - PasswordResetEnabled: passwordResetEnabled, - InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", - TotpEnabled: settings[SettingKeyTotpEnabled] == "true", - PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), - LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, - LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), - LoginAgreementDocuments: loginAgreementDocuments, - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", - PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), - TableDefaultPageSize: tableDefaultPageSize, - TablePageSizeOptions: tablePageSizeOptions, - CustomMenuItems: settings[SettingKeyCustomMenuItems], - CustomEndpoints: settings[SettingKeyCustomEndpoints], - LinuxDoOAuthEnabled: linuxDoEnabled, - DingTalkOAuthEnabled: dingTalkEnabled, - WeChatOAuthEnabled: weChatEnabled, - WeChatOAuthOpenEnabled: weChatOpenEnabled, - WeChatOAuthMPEnabled: weChatMPEnabled, - WeChatOAuthMobileEnabled: weChatMobileEnabled, - BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", - PaymentEnabled: settings[SettingPaymentEnabled] == "true", - OIDCOAuthEnabled: oidcEnabled, - OIDCOAuthProviderName: oidcProviderName, - GitHubOAuthEnabled: gitHubEnabled, - GoogleOAuthEnabled: googleEnabled, - BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", - AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", - BalanceLowNotifyThreshold: balanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", + RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true", + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings), + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), + LoginAgreementDocuments: loginAgreementDocuments, + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true", + TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID], + TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]), + AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true", + AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID], + AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix], + AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]), + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true", + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + TableDefaultPageSize: tableDefaultPageSize, + TablePageSizeOptions: tablePageSizeOptions, + CustomMenuItems: settings[SettingKeyCustomMenuItems], + CustomEndpoints: settings[SettingKeyCustomEndpoints], + LinuxDoOAuthEnabled: linuxDoEnabled, + DingTalkOAuthEnabled: dingTalkEnabled, + WeChatOAuthEnabled: weChatEnabled, + WeChatOAuthOpenEnabled: weChatOpenEnabled, + WeChatOAuthMPEnabled: weChatMPEnabled, + WeChatOAuthMobileEnabled: weChatMobileEnabled, + BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + PaymentEnabled: settings[SettingPaymentEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, + GitHubOAuthEnabled: gitHubEnabled, + GoogleOAuthEnabled: googleEnabled, + BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", + AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", + BalanceLowNotifyThreshold: balanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), + ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), + ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", @@ -353,8 +375,21 @@ const ( channelMonitorIntervalMin = 15 channelMonitorIntervalMax = 3600 channelMonitorIntervalFallback = 60 + defaultChannelMonitorMode = ChannelMonitorModeV1 ) +// normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default). +func normalizeChannelMonitorMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case ChannelMonitorModeV1, "": + return ChannelMonitorModeV1 + case ChannelMonitorModeV2: + return ChannelMonitorModeV2 + default: + return defaultChannelMonitorMode + } +} + // parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. // Empty / invalid input falls back to channelMonitorIntervalFallback. func parseChannelMonitorInterval(raw string) int { @@ -380,25 +415,55 @@ func clampChannelMonitorInterval(v int) int { } // ChannelMonitorRuntime is the lightweight view of the channel monitor feature -// consumed by the runner and user-facing handlers. +// consumed by the runner, V2 aggregator, and user-facing handlers. type ChannelMonitorRuntime struct { Enabled bool + Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2 DefaultIntervalSeconds int + // HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals. + HideThroughput bool +} + +// ActiveProbesAllowed reports whether V1 active provider probes may run. +func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool { + return r.Enabled && r.Mode == ChannelMonitorModeV1 +} + +// PassiveAggregationAllowed reports whether V2 passive aggregation may run. +func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool { + return r.Enabled && r.Mode == ChannelMonitorModeV2 } // GetChannelMonitorRuntime reads the channel monitor feature flags directly from -// the settings store. Fail-open: on error returns Enabled=true with the default interval. +// the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval. func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { + if s == nil || s.settingRepo == nil { + return ChannelMonitorRuntime{ + Enabled: true, + Mode: defaultChannelMonitorMode, + DefaultIntervalSeconds: channelMonitorIntervalFallback, + HideThroughput: true, + } + } vals, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorMode, SettingKeyChannelMonitorDefaultIntervalSeconds, + SettingKeyChannelMonitorHideThroughput, }) if err != nil { - return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} + return ChannelMonitorRuntime{ + Enabled: true, + Mode: defaultChannelMonitorMode, + DefaultIntervalSeconds: channelMonitorIntervalFallback, + HideThroughput: true, + } } return ChannelMonitorRuntime{ Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), + Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]), DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), + HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), } } @@ -473,48 +538,57 @@ func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool { // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch // drift automatically (see setting_service_injection_test.go). type PublicSettingsInjectionPayload struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - PasskeyEnabled bool `json:"passkey_enabled"` - LoginAgreementEnabled bool `json:"login_agreement_enabled"` - LoginAgreementMode string `json:"login_agreement_mode"` - LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` - LoginAgreementRevision string `json:"login_agreement_revision"` - LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - GitHubOAuthEnabled bool `json:"github_oauth_enabled"` - GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + PasskeyEnabled bool `json:"passkey_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"` + TencentCaptchaAppID string `json:"tencent_captcha_app_id"` + TencentCaptchaRegion string `json:"tencent_captcha_region"` + AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"` + AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"` + AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"` + AliyunCaptchaRegion string `json:"aliyun_captcha_region"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + CompactHomeEnabled bool `json:"compact_home_enabled"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 ServerTimezone string `json:"server_timezone"` ServerUTCOffset string `json:"server_utc_offset"` @@ -526,14 +600,18 @@ type PublicSettingsInjectionPayload struct { // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug // that hid the "可用渠道" menu on page refresh. - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` - 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"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + // 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"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -545,57 +623,68 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any } return &PublicSettingsInjectionPayload{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - PasskeyEnabled: settings.PasskeyEnabled, - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementRevision: settings.LoginAgreementRevision, - LoginAgreementDocuments: settings.LoginAgreementDocuments, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), - CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, - WeChatOAuthEnabled: settings.WeChatOAuthEnabled, - WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, - WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, - WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, - OIDCOAuthEnabled: settings.OIDCOAuthEnabled, - OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, - Version: s.version, - ServerTimezone: timezone.Name(), - ServerUTCOffset: timezone.UTCOffset(), - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + PasskeyEnabled: settings.PasskeyEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: settings.LoginAgreementDocuments, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TencentCaptchaEnabled: settings.TencentCaptchaEnabled, + TencentCaptchaAppID: settings.TencentCaptchaAppID, + TencentCaptchaRegion: settings.TencentCaptchaRegion, + AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled, + AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID, + AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix, + AliyunCaptchaRegion: settings.AliyunCaptchaRegion, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + CompactHomeEnabled: settings.CompactHomeEnabled, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), + CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, + WeChatOAuthEnabled: settings.WeChatOAuthEnabled, + WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, + WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, + WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, + Version: s.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorMode: settings.ChannelMonitorMode, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 35bcccb823..f9091e8a27 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -5,13 +5,84 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/sync/singleflight" + "sync" ) +const ( + GrokDefaultBaseURLModeAPI = "api" + GrokDefaultBaseURLModeUSEast1 = "us-east-1" + GrokDefaultBaseURLModeUSWest2 = "us-west-2" + GrokDefaultBaseURLModeEUWest1 = "eu-west-1" + GrokDefaultBaseURLModeCLI = "cli" +) + +func normalizeGrokDefaultBaseURLMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case GrokDefaultBaseURLModeAPI: + return GrokDefaultBaseURLModeAPI + case GrokDefaultBaseURLModeUSEast1: + return GrokDefaultBaseURLModeUSEast1 + case GrokDefaultBaseURLModeUSWest2: + return GrokDefaultBaseURLModeUSWest2 + case GrokDefaultBaseURLModeEUWest1: + return GrokDefaultBaseURLModeEUWest1 + case GrokDefaultBaseURLModeCLI: + return GrokDefaultBaseURLModeCLI + default: + return GrokDefaultBaseURLModeCLI + } +} + +func GrokBaseURLForMode(mode string) string { + switch normalizeGrokDefaultBaseURLMode(mode) { + case GrokDefaultBaseURLModeAPI: + return xai.DefaultBaseURL + case GrokDefaultBaseURLModeUSEast1: + return xai.DefaultUSEast1BaseURL + case GrokDefaultBaseURLModeUSWest2: + return xai.DefaultUSWest2BaseURL + case GrokDefaultBaseURLModeEUWest1: + return xai.DefaultEUWest1BaseURL + default: + return xai.DefaultCLIBaseURL + } +} + +func (s *SettingService) GetGrokDefaultBaseURLMode(ctx context.Context) string { + if s == nil || s.settingRepo == nil { + return GrokDefaultBaseURLModeCLI + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultBaseURLMode) + if err != nil { + return GrokDefaultBaseURLModeCLI + } + return normalizeGrokDefaultBaseURLMode(raw) +} + +func (s *SettingService) GetGrokDefaultBaseURL(ctx context.Context) string { + return GrokBaseURLForMode(s.GetGrokDefaultBaseURLMode(ctx)) +} + +func (s *SettingService) ResolveGrokBaseURL(ctx context.Context, account *Account) string { + def := xai.DefaultCLIBaseURL + if s != nil { + def = s.GetGrokDefaultBaseURL(ctx) + } + if account == nil { + return def + } + return account.GetGrokBaseURLOr(def) +} + var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") @@ -57,6 +128,8 @@ type SettingService struct { antigravityUAVersionSF singleflight.Group openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent openAICodexUASF singleflight.Group + openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion + openAICodexVersionSF singleflight.Group codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group @@ -76,6 +149,9 @@ type SettingService struct { // instance owns its own cache, no shared package-level state. openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings openAIQuotaAutoPauseSettingsSF singleflight.Group + + channelMonitorRuntimeListenersMu sync.Mutex + channelMonitorRuntimeListeners []func() } // DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限) @@ -300,6 +376,52 @@ func (s *SettingService) SetOnUpdateCallback(callback func()) { s.onUpdate = callback } +// SubscribeChannelMonitorRuntime registers a listener that is invoked after +// settings are successfully persisted (and process caches refreshed). +// Used by ChannelMonitorRunner / ChannelMonitorV2Aggregator for immediate +// mode flips without waiting for poll intervals. +func (s *SettingService) SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) { + if s == nil || listener == nil { + return func() {} + } + s.channelMonitorRuntimeListenersMu.Lock() + s.channelMonitorRuntimeListeners = append(s.channelMonitorRuntimeListeners, listener) + idx := len(s.channelMonitorRuntimeListeners) - 1 + s.channelMonitorRuntimeListenersMu.Unlock() + return func() { + s.channelMonitorRuntimeListenersMu.Lock() + defer s.channelMonitorRuntimeListenersMu.Unlock() + if idx < 0 || idx >= len(s.channelMonitorRuntimeListeners) { + return + } + s.channelMonitorRuntimeListeners[idx] = nil + } +} + +func (s *SettingService) notifyChannelMonitorRuntimeListeners() { + if s == nil { + return + } + s.channelMonitorRuntimeListenersMu.Lock() + listeners := make([]func(), 0, len(s.channelMonitorRuntimeListeners)) + for _, l := range s.channelMonitorRuntimeListeners { + if l != nil { + listeners = append(listeners, l) + } + } + s.channelMonitorRuntimeListenersMu.Unlock() + for _, l := range listeners { + func(fn func()) { + defer func() { + if recovered := recover(); recovered != nil { + _ = recovered // keep settings path healthy + } + }() + fn() + }(l) + } +} + // SetVersion sets the application version for injection into public settings func (s *SettingService) SetVersion(version string) { s.version = version diff --git a/backend/internal/service/setting_service_platform_threshold_test.go b/backend/internal/service/setting_service_platform_threshold_test.go new file mode 100644 index 0000000000..3e4543e0e0 --- /dev/null +++ b/backend/internal/service/setting_service_platform_threshold_test.go @@ -0,0 +1,171 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newSettingServiceForPlatformThresholdTest(seed map[string]string) *SettingService { + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + repo := newMockSettingRepo() + for k, v := range seed { + repo.data[k] = v + } + return NewSettingService(repo, &config.Config{}) +} + +func TestPlatformSchedulingThresholds_RoundTrip_DefaultsAndStoredValues(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(nil) + + got := svc.parseSettings(map[string]string{}) + require.Equal(t, map[string]int{ + PlatformOpenAI: 100, + PlatformAnthropic: 100, + PlatformGrok: 100, + }, got.AccountSchedulingThresholds) + + got = svc.parseSettings(map[string]string{ + SettingKeyAccountSchedulingThresholds: `{"openai":91,"grok":77,"gemini":85,"kiro":99}`, + }) + require.Equal(t, 91, got.AccountSchedulingThresholds[PlatformOpenAI]) + require.Equal(t, 100, got.AccountSchedulingThresholds[PlatformAnthropic]) + require.Equal(t, 77, got.AccountSchedulingThresholds[PlatformGrok]) + require.NotContains(t, got.AccountSchedulingThresholds, PlatformGemini) + require.NotContains(t, got.AccountSchedulingThresholds, "kiro") +} + +func TestBuildSystemSettingsUpdates_PersistsAccountSchedulingThresholds(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(nil) + + updates, err := svc.buildSystemSettingsUpdates(context.Background(), &SystemSettings{ + AccountSchedulingThresholds: map[string]int{ + PlatformOpenAI: 91, + PlatformAnthropic: 88, + PlatformGrok: 77, + }, + }) + require.NoError(t, err) + require.JSONEq(t, `{"openai":91,"anthropic":88,"grok":77}`, updates[SettingKeyAccountSchedulingThresholds]) +} + +func TestValidateAndNormalizeAccountSchedulingThresholds_FillsMissingPlatforms(t *testing.T) { + normalized, err := validateAndNormalizeAccountSchedulingThresholds(map[string]int{ + PlatformOpenAI: 91, + }) + require.NoError(t, err) + require.Equal(t, 91, normalized[PlatformOpenAI]) + require.Equal(t, 100, normalized[PlatformAnthropic]) + require.Equal(t, 100, normalized[PlatformGrok]) + require.NotContains(t, normalized, PlatformGemini) + require.NotContains(t, normalized, "kiro") + require.NotContains(t, normalized, PlatformAntigravity) +} + +func TestValidateAndNormalizeAccountSchedulingThresholds_RejectsUnsupportedPlatforms(t *testing.T) { + _, err := validateAndNormalizeAccountSchedulingThresholds(map[string]int{ + PlatformGemini: 85, + }) + require.Error(t, err) +} + +func TestUpdateSettings_StoresAccountSchedulingThresholds(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(nil) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + AccountSchedulingThresholds: map[string]int{ + PlatformOpenAI: 92, + PlatformAnthropic: 89, + PlatformGrok: 76, + }, + }) + require.NoError(t, err) + + got := svc.parseSettings(map[string]string{ + SettingKeyAccountSchedulingThresholds: svc.settingRepo.(*mockSettingRepo).data[SettingKeyAccountSchedulingThresholds], + }) + require.Equal(t, 92, got.AccountSchedulingThresholds[PlatformOpenAI]) + require.Equal(t, 89, got.AccountSchedulingThresholds[PlatformAnthropic]) + require.Equal(t, 76, got.AccountSchedulingThresholds[PlatformGrok]) + require.NotContains(t, got.AccountSchedulingThresholds, "kiro") +} + +func TestGetAccountSchedulingThresholds_ReadsStoredValue(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(map[string]string{ + SettingKeyAccountSchedulingThresholds: `{"openai":93,"grok":88,"kiro":87}`, + }) + + got := svc.GetAccountSchedulingThresholds(context.Background()) + + require.Equal(t, 93, got[PlatformOpenAI]) + require.Equal(t, 100, got[PlatformAnthropic]) + require.Equal(t, 88, got[PlatformGrok]) + require.NotContains(t, got, "kiro") +} + +func TestGetAccountSchedulingThresholds_MissingSettingUsesDefaultsAndNormalCacheTTL(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(nil) + repo := svc.settingRepo.(*mockSettingRepo) + repo.getValueErr = ErrSettingNotFound + + got := svc.GetAccountSchedulingThresholds(context.Background()) + require.Equal(t, defaultAccountSchedulingThresholds(), got) + require.Equal(t, 1, repo.getValueCalls) + + repo.data[SettingKeyAccountSchedulingThresholds] = `{"openai":91}` + got = svc.GetAccountSchedulingThresholds(context.Background()) + require.Equal(t, 100, got[PlatformOpenAI], "missing-setting defaults should remain cached for the normal TTL") + require.Equal(t, 1, repo.getValueCalls) + + cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds) + require.True(t, ok) + require.Greater(t, cached.expiresAt, time.Now().Add(accountSchedulingThresholdsCacheTTL-time.Second).UnixNano()) +} + +func TestUpdateSettings_OmittedAccountSchedulingThresholdsDoesNotCacheDefaults(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(map[string]string{ + SettingKeyAccountSchedulingThresholds: `{"openai":85,"grok":88,"kiro":87}`, + }) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + FrontendURL: "https://example.test", + }) + require.NoError(t, err) + + got := svc.GetAccountSchedulingThresholds(context.Background()) + require.Equal(t, 85, got[PlatformOpenAI]) + require.Equal(t, 88, got[PlatformGrok]) + require.NotContains(t, got, "kiro") +} + +func TestAccountSchedulingThresholds_InvalidStoredValueUsesSameDefaultsInSettingsAndCache(t *testing.T) { + svc := newSettingServiceForPlatformThresholdTest(map[string]string{ + SettingKeyAccountSchedulingThresholds: `{"openai":0,"grok":88,"kiro":87}`, + }) + + settings := svc.parseSettings(map[string]string{ + SettingKeyAccountSchedulingThresholds: `{"openai":0,"grok":88,"kiro":87}`, + }) + cached := svc.GetAccountSchedulingThresholds(context.Background()) + + require.Equal(t, settings.AccountSchedulingThresholds, cached) + require.Equal(t, 100, cached[PlatformOpenAI]) + require.Equal(t, 88, cached[PlatformGrok]) + require.NotContains(t, cached, "kiro") +} + +func TestGetAccountSchedulingThresholds_NilRepoReturnsDefaults(t *testing.T) { + svc := &SettingService{} + got := svc.GetAccountSchedulingThresholds(context.Background()) + require.Equal(t, map[string]int{ + PlatformOpenAI: 100, + PlatformAnthropic: 100, + PlatformGrok: 100, + }, got) +} diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 621620fd01..67bab243ca 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -12,6 +12,7 @@ import ( type settingPublicRepoStub struct { values map[string]string + err error } func (s *settingPublicRepoStub) Get(ctx context.Context, key string) (*Setting, error) { @@ -27,6 +28,9 @@ func (s *settingPublicRepoStub) Set(ctx context.Context, key, value string) erro } func (s *settingPublicRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if s.err != nil { + return nil, s.err + } out := make(map[string]string, len(keys)) for _, key := range keys { if value, ok := s.values[key]; ok { @@ -78,6 +82,40 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions) } +func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T) { + repo := &settingPublicRepoStub{ + values: map[string]string{ + SettingKeyCompactHomeEnabled: "true", + }, + } + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + + require.NoError(t, err) + require.True(t, settings.CompactHomeEnabled) + + missingSettings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}). + GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, missingSettings.CompactHomeEnabled) +} + +func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing.T) { + missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, missing.HideThroughput) + public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, public.ChannelMonitorHideThroughput) + + for _, value := range []string{"false", "0", "off", "disabled"} { + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorHideThroughput: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, runtime.HideThroughput, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index b689f56781..750f01728b 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -228,6 +228,16 @@ func (s *defaultSubGroupReaderStub) GetByID(ctx context.Context, id int64) (*Gro return nil, ErrGroupNotFound } +func TestSettingService_UpdateSettings_PersistsCompactHomeEnabled(t *testing.T) { + repo := &settingUpdateRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{CompactHomeEnabled: true}) + + require.NoError(t, err) + require.Equal(t, "true", repo.updates[SettingKeyCompactHomeEnabled]) +} + func TestSettingService_UpdateSettings_DefaultSubscriptions_ValidGroup(t *testing.T) { repo := &settingUpdateRepoStub{} groupReader := &defaultSubGroupReaderStub{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index d094f9026f..f77a914fd7 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -169,6 +169,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err) } updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) + updates[SettingKeyRegistrationEmailDomainQuotaEnabled] = strconv.FormatBool(settings.RegistrationEmailDomainQuotaEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) updates[SettingKeyFrontendURL] = settings.FrontendURL @@ -209,6 +210,28 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.TurnstileSecretKey != "" { updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } + + updates[SettingKeyTencentCaptchaEnabled] = strconv.FormatBool(settings.TencentCaptchaEnabled) + updates[SettingKeyTencentCaptchaAppID] = settings.TencentCaptchaAppID + if settings.TencentCaptchaAppSecretKey != "" { + updates[SettingKeyTencentCaptchaAppSecretKey] = settings.TencentCaptchaAppSecretKey + } + if settings.TencentCaptchaCloudSecretID != "" { + updates[SettingKeyTencentCaptchaCloudSecretID] = settings.TencentCaptchaCloudSecretID + } + if settings.TencentCaptchaCloudSecretKey != "" { + updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey + } + updates[SettingKeyTencentCaptchaRegion] = normalizeTencentCaptchaRegion(settings.TencentCaptchaRegion) + // 阿里云验证码 2.0 设置(只有非空才更新密钥) + updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled) + updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID + if settings.AliyunCaptchaAccessKeySecret != "" { + updates[SettingKeyAliyunCaptchaAccessKeySecret] = settings.AliyunCaptchaAccessKeySecret + } + updates[SettingKeyAliyunCaptchaSceneID] = settings.AliyunCaptchaSceneID + updates[SettingKeyAliyunCaptchaPrefix] = settings.AliyunCaptchaPrefix + updates[SettingKeyAliyunCaptchaRegion] = normalizeAliyunCaptchaRegion(settings.AliyunCaptchaRegion) updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders) if err != nil { @@ -320,6 +343,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyContactInfo] = settings.ContactInfo updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyHomeContent] = settings.HomeContent + updates[SettingKeyCompactHomeEnabled] = strconv.FormatBool(settings.CompactHomeEnabled) updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) @@ -388,9 +412,20 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // Channel monitor feature switch updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) + updates[SettingKeyChannelMonitorMode] = normalizeChannelMonitorMode(settings.ChannelMonitorMode) if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) } + updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) + + // Grok model mapping policy + if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { + updates[SettingKeyGrokDefaultTextModel] = v + } else { + updates[SettingKeyGrokDefaultTextModel] = "grok-4.5" + } + updates[SettingKeyGrokCrossClientModelMapEnabled] = strconv.FormatBool(settings.GrokCrossClientModelMapEnabled) + updates[SettingKeyGrokDefaultBaseURLMode] = normalizeGrokDefaultBaseURLMode(settings.GrokDefaultBaseURLMode) // Available channels feature switch updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) @@ -437,6 +472,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization) updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) + updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion) + updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled) + // SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, + // 否则面板保存会把同步结果清空。 // codex_cli_only 加固 updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) @@ -484,12 +523,88 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting } updates[SettingKeyDefaultPlatformQuotas] = string(blob) } + if settings.AccountSchedulingThresholds != nil { + normalized, err := validateAndNormalizeAccountSchedulingThresholds(settings.AccountSchedulingThresholds) + if err != nil { + return nil, err + } + blob, err := json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("marshal account scheduling thresholds: %w", err) + } + updates[SettingKeyAccountSchedulingThresholds] = string(blob) + } updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests) return updates, nil } +func defaultAccountSchedulingThresholds() map[string]int { + return map[string]int{ + PlatformOpenAI: 100, + PlatformAnthropic: 100, + PlatformGrok: 100, + } +} + +func validateAndNormalizeAccountSchedulingThresholds(input map[string]int) (map[string]int, error) { + normalized := defaultAccountSchedulingThresholds() + for platform, value := range input { + allowed := false + for _, item := range AllowedSchedulingThresholdPlatforms { + if item == platform { + allowed = true + break + } + } + if !allowed { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_SCHEDULING_THRESHOLDS", fmt.Sprintf("unknown platform %q", platform)) + } + if value < 1 || value > 100 { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_SCHEDULING_THRESHOLDS", "platform scheduling threshold must be between 1 and 100") + } + normalized[platform] = value + } + return normalized, nil +} + +func parseAccountSchedulingThresholdsSetting(raw string) (map[string]int, error) { + thresholds := defaultAccountSchedulingThresholds() + raw = strings.TrimSpace(raw) + if raw == "" { + return thresholds, nil + } + parsed := map[string]int{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return thresholds, err + } + for _, platform := range AllowedSchedulingThresholdPlatforms { + if value, ok := parsed[platform]; ok { + thresholds[platform] = boundedIntOrDefault(value, 1, 100, 100) + } + } + return thresholds, nil +} + +func boundedIntOrDefault(value, minValue, maxValue, defaultValue int) int { + if value < minValue || value > maxValue { + return defaultValue + } + return value +} + +func cloneAccountSchedulingThresholds(input map[string]int) map[string]int { + if len(input) == 0 { + return defaultAccountSchedulingThresholds() + } + cloned := make(map[string]int, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} + // validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性: // 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。 // 系统层和 auth-source 层共用此 helper。 @@ -609,6 +724,9 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { value: codexUA, expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(), }) + // 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键, + // 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。 + s.InvalidateOpenAICodexClientVersionCache() openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled, @@ -642,6 +760,20 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { expiresAt: 0, }) } + accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds) + if settings.AccountSchedulingThresholds != nil { + normalizedThresholds, err := validateAndNormalizeAccountSchedulingThresholds(settings.AccountSchedulingThresholds) + if err != nil { + normalizedThresholds = defaultAccountSchedulingThresholds() + } + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{ + thresholds: cloneAccountSchedulingThresholds(normalizedThresholds), + expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(), + }) + } else { + // Partial/omitted payload: clear cache so the next hot-path read reloads from DB. + accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{}) + } if s.cfg != nil { s.cfg.SetForwardedClientIPSettings(settings.APIKeyACLTrustForwardedIP, settings.ForwardedClientIPHeaders) } @@ -651,6 +783,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } + s.notifyChannelMonitorRuntimeListeners() } func (s *SettingService) defaultRewriteMessageCacheControl() bool { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 4a6d3906f3..8580f0c8fb 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -12,22 +12,23 @@ func firstNonEmpty(values ...string) string { } type SystemSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - RegistrationEmailSuffixWhitelist []string - PromoCodeEnabled bool - PasswordResetEnabled bool - FrontendURL string - InvitationCodeEnabled bool - TotpEnabled bool // TOTP 双因素认证 - PasskeyEnabled bool // Passkey 登录 - SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) - StepUpEnabled bool // 敏感操作 step-up 2FA 门控 - AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) - LoginAgreementEnabled bool - LoginAgreementMode string - LoginAgreementUpdatedAt string - LoginAgreementDocuments []LoginAgreementDocument + RegistrationEnabled bool + EmailVerifyEnabled bool + RegistrationEmailSuffixWhitelist []string + RegistrationEmailDomainQuotaEnabled bool // 白名单非空时放行非白名单域名限量注册(默认关闭) + PromoCodeEnabled bool + PasswordResetEnabled bool + FrontendURL string + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool // Passkey 登录 + SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效) + StepUpEnabled bool // 敏感操作 step-up 2FA 门控 + AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留) + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementDocuments []LoginAgreementDocument SMTPHost string SMTPPort int @@ -38,12 +39,28 @@ type SystemSettings struct { SMTPFromName string SMTPUseTLS bool - TurnstileEnabled bool - TurnstileSiteKey string - TurnstileSecretKey string - TurnstileSecretKeyConfigured bool - APIKeyACLTrustForwardedIP bool - ForwardedClientIPHeaders []string + TurnstileEnabled bool + TurnstileSiteKey string + TurnstileSecretKey string + TurnstileSecretKeyConfigured bool + TencentCaptchaEnabled bool + TencentCaptchaAppID string + TencentCaptchaAppSecretKey string + TencentCaptchaAppSecretKeyConfigured bool + TencentCaptchaCloudSecretID string + TencentCaptchaCloudSecretIDConfigured bool + TencentCaptchaCloudSecretKey string + TencentCaptchaCloudSecretKeyConfigured bool + TencentCaptchaRegion string + AliyunCaptchaEnabled bool + AliyunCaptchaAccessKeyID string + AliyunCaptchaAccessKeySecret string + AliyunCaptchaAccessKeySecretConfigured bool + AliyunCaptchaSceneID string + AliyunCaptchaPrefix string + AliyunCaptchaRegion string + APIKeyACLTrustForwardedIP bool + ForwardedClientIPHeaders []string // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool @@ -139,6 +156,7 @@ type SystemSettings struct { ContactInfo string DocURL string HomeContent string + CompactHomeEnabled bool HideCcsImportButton bool PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string @@ -179,8 +197,15 @@ type SystemSettings struct { OpsMetricsIntervalSeconds int // Channel Monitor feature - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + + // Grok model mapping policy (admin settings; empty mapping falls back to these). + GrokDefaultTextModel string `json:"grok_default_text_model"` + GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -211,7 +236,10 @@ type SystemSettings struct { EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true) RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值 - OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认 + OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 TUI UA + OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值 + OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示) + OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true) MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) @@ -272,6 +300,9 @@ type SystemSettings struct { // 系统全局默认平台配额(key = platform,nil/缺省 = 不限制) DefaultPlatformQuotas map[string]*DefaultPlatformQuotaSetting `json:"default_platform_quotas"` + // 系统全局账号自动停调阈值(key = platform,100 = disabled) + AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds"` + // 允许终端用户在用量页查看自己的失败请求 AllowUserViewErrorRequests bool } @@ -282,30 +313,39 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - ForceEmailOnThirdPartySignup bool - RegistrationEmailSuffixWhitelist []string - PromoCodeEnabled bool - PasswordResetEnabled bool - InvitationCodeEnabled bool - TotpEnabled bool // TOTP 双因素认证 - PasskeyEnabled bool - LoginAgreementEnabled bool - LoginAgreementMode string - LoginAgreementUpdatedAt string - LoginAgreementRevision string - LoginAgreementDocuments []LoginAgreementDocument - TurnstileEnabled bool - TurnstileSiteKey string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - HideCcsImportButton bool + RegistrationEnabled bool + EmailVerifyEnabled bool + ForceEmailOnThirdPartySignup bool + RegistrationEmailSuffixWhitelist []string + RegistrationEmailDomainQuotaEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + PasskeyEnabled bool + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementRevision string + LoginAgreementDocuments []LoginAgreementDocument + TurnstileEnabled bool + TurnstileSiteKey string + TencentCaptchaEnabled bool + TencentCaptchaAppID string + TencentCaptchaRegion string + AliyunCaptchaEnabled bool + AliyunCaptchaSceneID string + AliyunCaptchaPrefix string + AliyunCaptchaRegion string + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + CompactHomeEnabled bool + HideCcsImportButton bool PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string @@ -334,8 +374,15 @@ type PublicSettings struct { BalanceLowNotifyRechargeURL string // Channel Monitor feature - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorMode string `json:"channel_monitor_mode"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` + + // Grok model mapping policy (admin settings). + GrokDefaultTextModel string `json:"grok_default_text_model"` + GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 151b5c7928..ed6c4e0aae 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -9,6 +9,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/dgraph-io/ristretto" "github.com/stretchr/testify/require" ) @@ -111,6 +112,9 @@ func (userSubRepoNoop) Create(context.Context, *UserSubscription) error { func (userSubRepoNoop) GetByID(context.Context, int64) (*UserSubscription, error) { panic("unexpected GetByID call") } +func (userSubRepoNoop) GetByIDForUpdate(context.Context, int64) (*UserSubscription, error) { + panic("unexpected GetByIDForUpdate call") +} func (userSubRepoNoop) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) { panic("unexpected GetByIDIncludeDeleted call") } @@ -154,10 +158,10 @@ func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error { func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error { panic("unexpected UpdateNotes call") } -func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time) error { +func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error { panic("unexpected ActivateWindows call") } -func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { panic("unexpected ResetUsageWindows call") } func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error { @@ -249,6 +253,10 @@ func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*Use return &cp, nil } +func (s *subscriptionUserSubRepoStub) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) { + return s.GetByID(ctx, id) +} + func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscription) error { if sub == nil { return ErrSubscriptionNilInput @@ -417,9 +425,9 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) { require.False(t, sub.StartsAt.Before(before)) require.False(t, sub.StartsAt.After(after)) require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt) - require.Equal(t, startOfDay(sub.StartsAt), *sub.DailyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart) + require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点") + require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart) + require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart) require.Zero(t, sub.DailyUsageUSD) require.Zero(t, sub.WeeklyUsageUSD) require.Zero(t, sub.MonthlyUsageUSD) diff --git a/backend/internal/service/subscription_daily_midnight_reset_test.go b/backend/internal/service/subscription_daily_midnight_reset_test.go new file mode 100644 index 0000000000..5f16847511 --- /dev/null +++ b/backend/internal/service/subscription_daily_midnight_reset_test.go @@ -0,0 +1,162 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +// dailyMidnightResetRepo 记录 ResetDailyUsage 收到的新窗口起点。 +type dailyMidnightResetRepo struct { + userSubRepoNoop + + resetCalled bool + newWindowStart time.Time +} + +func (r *dailyMidnightResetRepo) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, newWindowStart time.Time) error { + r.resetCalled = true + r.newWindowStart = newWindowStart + return nil +} + +// midnightTestBase 返回一个固定日期在配置时区的 0 点,测试用时刻均从它推导, +// 保证断言在任意本地时区下都与生产逻辑同构。 +func midnightTestBase() time.Time { + return timezone.StartOfDay(time.Date(2026, 8, 6, 12, 0, 0, 0, timezone.Location())) +} + +func newMidnightTestSub(dailyWindowStart time.Time, base time.Time) *UserSubscription { + start := dailyWindowStart + return &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: base.AddDate(0, 0, -3), + ExpiresAt: base.AddDate(0, 0, 30), + DailyUsageUSD: 43.34, + DailyWindowStart: &start, + } +} + +// 手动重置(或任何原因)留下的非 0 点锚点,跨 0 点后必须重置, +// 且新窗口起点是当天 0 点,而不是锚点+24h 的滚动时刻。 +func TestCheckAndResetWindows_DailyResetsAtMidnightNotRollingAnchor(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) // 昨日 16:49 手动重置 + now := base.AddDate(0, 0, 1).Add(5 * time.Minute) // 次日 00:05 + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(manualResetAt, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.True(t, repo.resetCalled, "跨 0 点后日窗口必须重置") + require.Equal(t, base.AddDate(0, 0, 1), repo.newWindowStart, "新窗口起点应为当天 0 点") + require.Zero(t, sub.DailyUsageUSD) + require.Equal(t, base.AddDate(0, 0, 1), *sub.DailyWindowStart) +} + +// 同一日历日内(即使已过锚点+若干小时)不得重置:手动重置当天剩余时间继续累计用量。 +func TestCheckAndResetWindows_DailyNoResetWithinSameCalendarDay(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) + now := base.Add(23*time.Hour + 59*time.Minute) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(manualResetAt, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.False(t, repo.resetCalled, "同一日历日内不应重置日窗口") + require.Equal(t, 43.34, sub.DailyUsageUSD) +} + +// 0.1.170/171 期间产生的滚动锚点(如多日前的 17:18)自愈:下一次维护即拉回今天 0 点。 +func TestCheckAndResetWindows_LegacyRollingAnchorHealsToMidnight(t *testing.T) { + base := midnightTestBase() + staleAnchor := base.AddDate(0, 0, -3).Add(17*time.Hour + 18*time.Minute) + now := base.Add(10 * time.Hour) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := newMidnightTestSub(staleAnchor, base) + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.True(t, repo.resetCalled) + require.Equal(t, base, repo.newWindowStart, "滚动锚点应被拉回今天 0 点,而非按 17:18 步进") +} + +// 手动重置写入的 0 点锚点不会改变刷新节奏:次日 0 点照常需要重置。 +func TestNeedsDailyReset_MidnightScheduleSurvivesManualReset(t *testing.T) { + base := midnightTestBase() + sub := newMidnightTestSub(base, base) // 手动重置后锚点=当天 0 点 + + require.False(t, sub.NeedsDailyResetAt(base.Add(23*time.Hour+54*time.Minute)), "当天 23:54 不应重置") + require.True(t, sub.NeedsDailyResetAt(base.AddDate(0, 0, 1).Add(time.Minute)), "次日 00:01 应重置") +} + +// 多日订阅的日窗口展示的下次刷新时间固定为次日 0 点(截图中「X 小时后重置」的数据源)。 +func TestDailyResetTime_NextMidnightForMultiDaySubscription(t *testing.T) { + base := midnightTestBase() + + // 0 点锚点 → 次日 0 点 + sub := newMidnightTestSub(base, base) + resetAt := sub.DailyResetTime() + require.NotNil(t, resetAt) + require.Equal(t, base.AddDate(0, 0, 1), *resetAt) + + // 非 0 点滚动锚点 → 仍是其所在日的次日 0 点,而非锚点+24h + rolling := newMidnightTestSub(base.Add(16*time.Hour+49*time.Minute), base) + resetAt = rolling.DailyResetTime() + require.NotNil(t, resetAt) + require.Equal(t, base.AddDate(0, 0, 1), *resetAt) +} + +// 截图场景:跨 0 点后(后台尚未收到请求推进窗口)列表展示即应清零日用量。 +func TestNormalizeExpiredWindows_DailyUsageClearsAfterMidnight(t *testing.T) { + base := midnightTestBase() + manualResetAt := base.Add(16*time.Hour + 49*time.Minute) + now := base.AddDate(0, 0, 1).Add(time.Minute) // 次日 00:01 + + subs := []UserSubscription{*newMidnightTestSub(manualResetAt, base)} + normalizeExpiredWindowsAt(subs, now) + + require.Zero(t, subs[0].DailyUsageUSD, "跨 0 点后展示的日用量应清零") + require.Nil(t, subs[0].DailyWindowStart) +} + +// 日卡(一次性日额度)不受 0 点语义影响:跨 0 点不重置。 +func TestCheckAndResetWindows_OneTimeDailyCardStillExemptFromMidnightReset(t *testing.T) { + base := midnightTestBase() + startsAt := base.Add(17 * time.Hour) + anchor := base + now := base.AddDate(0, 0, 1).Add(2 * time.Hour) + + repo := &dailyMidnightResetRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.AddDate(0, 0, 1), + DailyUsageUSD: 10, + DailyWindowStart: &anchor, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + + require.False(t, repo.resetCalled, "日卡为一次性配额,跨 0 点不应重置") + require.Equal(t, 10.0, sub.DailyUsageUSD) +} diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index 7db642c076..74b82e56c3 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -22,6 +22,10 @@ func (r *subscriptionExpiryRepoStub) GetByID(context.Context, int64) (*UserSubsc return nil, ErrSubscriptionNotFound } +func (r *subscriptionExpiryRepoStub) GetByIDForUpdate(context.Context, int64) (*UserSubscription, error) { + return nil, ErrSubscriptionNotFound +} + func (r *subscriptionExpiryRepoStub) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) { return nil, ErrSubscriptionNotFound } @@ -83,11 +87,11 @@ func (r *subscriptionExpiryRepoStub) UpdateNotes(context.Context, int64, string) return nil } -func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time) error { +func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time, time.Time) error { return nil } -func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error { +func (r *subscriptionExpiryRepoStub) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error { return nil } diff --git a/backend/internal/service/subscription_monthly_window_test.go b/backend/internal/service/subscription_monthly_window_test.go new file mode 100644 index 0000000000..3e71ca17ef --- /dev/null +++ b/backend/internal/service/subscription_monthly_window_test.go @@ -0,0 +1,264 @@ +//go:build unit + +package service + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/stretchr/testify/require" +) + +type activateWindowUserSubRepo struct { + userSubRepoNoop + dailyStart time.Time + periodicStart time.Time +} + +type monthlyResetUserSubRepo struct { + userSubRepoNoop + resetCalled bool + resetAt time.Time +} + +func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, resetAt time.Time) error { + r.resetCalled = true + r.resetAt = resetAt + return nil +} + +func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, dailyStart, periodicStart time.Time) error { + r.dailyStart = dailyStart + r.periodicStart = periodicStart + return nil +} + +func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) { + repo := &activateWindowUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + startsAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + activatedAt := time.Date(2026, 7, 10, 23, 30, 0, 0, time.UTC) + svc.now = func() time.Time { return activatedAt } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + } + + require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub)) + + require.Equal(t, activatedAt, repo.periodicStart) + require.Equal(t, timezone.StartOfDay(activatedAt), repo.dailyStart) + monthlyWindowStart := repo.periodicStart + resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour)) + require.True(t, ok) + require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt) + require.NotEqual(t, startsAt.Add(30*24*time.Hour), resetAt) +} + +func TestThirtyDaySubscriptionDoesNotResetMonthlyQuotaBeforeExpiry(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + expiresAt := startsAt.Add(30 * 24 * time.Hour) + renewed := renewedSubscriptionTerm(&UserSubscription{}, "", startsAt, expiresAt) + + require.Equal(t, startsAt, *renewed.MonthlyWindowStart) + require.False(t, renewed.NeedsMonthlyResetAt(expiresAt.Add(-time.Second))) + require.True(t, renewed.NeedsMonthlyResetAt(expiresAt)) + require.False(t, renewed.canAutomaticallyResetMonthlyAt(expiresAt)) + require.Equal(t, expiresAt, *renewed.MonthlyResetTime()) +} + +func TestCheckAndResetWindowsDoesNotResetExactThirtyDayLegacyMonthlyWindow(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.False(t, repo.resetCalled) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) + require.Equal(t, windowStart, *sub.MonthlyWindowStart) +} + +func TestCheckAndResetWindowsResetsPartialFinalMonthlySubscriptions(t *testing.T) { + for _, durationDays := range []int{31, 45} { + t.Run(strconv.Itoa(durationDays)+"_days", func(t *testing.T) { + legacyWindowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 2, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(time.Duration(durationDays) * 24 * time.Hour), + MonthlyWindowStart: &legacyWindowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.True(t, repo.resetCalled) + require.Equal(t, startsAt.Add(30*24*time.Hour), repo.resetAt) + require.Zero(t, sub.MonthlyUsageUSD) + }) + } +} + +func TestNormalizeExpiredWindowsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Equal(t, 12.0, subs[0].MonthlyUsageUSD) + require.Equal(t, windowStart, *subs[0].MonthlyWindowStart) +} + +func TestNormalizeExpiredWindowsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(31 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Zero(t, subs[0].MonthlyUsageUSD) + require.Nil(t, subs[0].MonthlyWindowStart) +} + +func TestValidateAndCheckLimitsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.ErrorIs(t, err, ErrMonthlyLimitExceeded) + require.False(t, needsMaintenance) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.NoError(t, err) + require.True(t, needsMaintenance) + require.Zero(t, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsRejectsExactExpiry(t *testing.T) { + now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + sub := &UserSubscription{Status: SubscriptionStatusActive, ExpiresAt: now} + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{}) + + require.ErrorIs(t, err, ErrSubscriptionExpired) + require.False(t, needsMaintenance) +} + +func TestAutomaticWindowsAllowPartialFinalDailyAndWeeklyPeriods(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 45, 0, 0, time.UTC) + legacyWindowStart := startOfDay(startsAt) + tests := []struct { + name string + period time.Duration + expiresAt time.Time + }{ + {name: "daily", period: 24 * time.Hour, expiresAt: startsAt.Add(36 * time.Hour)}, + {name: "weekly", period: 7 * 24 * time.Hour, expiresAt: startsAt.Add(10 * 24 * time.Hour)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: tt.expiresAt} + resetAt, ok := sub.automaticWindowStartAt(&legacyWindowStart, tt.period, startsAt.Add(tt.period)) + + require.True(t, ok) + require.Equal(t, startsAt.Add(tt.period), resetAt) + }) + } +} + +func TestAutomaticWindowPreservesPersistedManualAnchorAndAdvancesWholePeriods(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startsAt.Add(5 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(65*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(60*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesPeriodAlignedLaterMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startOfDay(startsAt).Add(30 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, startsAt.Add(60*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesExactMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(30*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt) +} diff --git a/backend/internal/service/subscription_renewal_lock_test.go b/backend/internal/service/subscription_renewal_lock_test.go new file mode 100644 index 0000000000..2e223f0196 --- /dev/null +++ b/backend/internal/service/subscription_renewal_lock_test.go @@ -0,0 +1,143 @@ +package service + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type lockingRenewalRepo struct { + userSubRepoNoop + mu sync.Mutex + stale UserSubscription + current UserSubscription + lockReads int +} + +func (r *lockingRenewalRepo) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { + return true, nil +} + +func (r *lockingRenewalRepo) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) { + copy := r.stale + return ©, nil +} + +func (r *lockingRenewalRepo) GetByID(_ context.Context, _ int64) (*UserSubscription, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := r.current + return ©, nil +} + +func (r *lockingRenewalRepo) GetByIDForUpdate(_ context.Context, _ int64) (*UserSubscription, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.lockReads++ + copy := r.current + return ©, nil +} + +func (r *lockingRenewalRepo) ExtendExpiry(_ context.Context, _ int64, expiresAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + r.current.ExpiresAt = expiresAt + return nil +} + +func (r *lockingRenewalRepo) UpdateStatus(_ context.Context, _ int64, status string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.current.Status = status + return nil +} + +func (r *lockingRenewalRepo) UpdateNotes(_ context.Context, _ int64, notes string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.current.Notes = notes + return nil +} + +func (r *lockingRenewalRepo) Update(_ context.Context, sub *UserSubscription) error { + r.mu.Lock() + defer r.mu.Unlock() + r.current = *sub + return nil +} + +func TestAssignOrExtendSubscriptionUsesLockedCurrentRow(t *testing.T) { + now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + lockedExpiry := now.AddDate(0, 0, 20) + windowStart := now.Add(-24 * time.Hour) + repo := &lockingRenewalRepo{ + stale: UserSubscription{ID: 7, UserID: 11, GroupID: 13, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired, Notes: "stale"}, + current: UserSubscription{ + ID: 7, UserID: 11, GroupID: 13, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: lockedExpiry, + Status: SubscriptionStatusSuspended, Notes: "current", DailyWindowStart: &windowStart, DailyUsageUSD: 4, + }, + } + svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 13, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + + sub, extended, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{ + UserID: 11, GroupID: 13, ValidityDays: 5, Notes: "renewed", + }) + + require.NoError(t, err) + require.True(t, extended) + require.Equal(t, 1, repo.lockReads) + require.Equal(t, lockedExpiry.AddDate(0, 0, 5), sub.ExpiresAt) + require.Equal(t, SubscriptionStatusActive, sub.Status) + require.Equal(t, "current\nrenewed", sub.Notes) + require.Equal(t, windowStart, *sub.DailyWindowStart) + require.Equal(t, float64(4), sub.DailyUsageUSD) +} + +func TestAssignOrExtendSubscriptionSerializedRenewalsAccumulateDays(t *testing.T) { + now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + initialExpiry := now.AddDate(0, 0, 10) + stale := UserSubscription{ID: 17, UserID: 21, GroupID: 23, StartsAt: now, ExpiresAt: initialExpiry, Status: SubscriptionStatusActive} + repo := &lockingRenewalRepo{stale: stale, current: stale} + svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 23, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + input := &AssignSubscriptionInput{UserID: 21, GroupID: 23, ValidityDays: 7} + + _, _, err := svc.AssignOrExtendSubscription(context.Background(), input) + require.NoError(t, err) + second, _, err := svc.AssignOrExtendSubscription(context.Background(), input) + require.NoError(t, err) + + require.Equal(t, 2, repo.lockReads) + require.Equal(t, initialExpiry.AddDate(0, 0, 14), second.ExpiresAt) +} + +func TestAssignSubscriptionDoesNotReactivateRowSuspendedAfterStaleRead(t *testing.T) { + now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) + windowStart := now.Add(-24 * time.Hour) + current := UserSubscription{ + ID: 27, UserID: 31, GroupID: 33, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: now.Add(-time.Hour), + Status: SubscriptionStatusSuspended, Notes: "suspended", DailyWindowStart: &windowStart, DailyUsageUSD: 4, + } + repo := &lockingRenewalRepo{ + stale: UserSubscription{ID: 27, UserID: 31, GroupID: 33, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired}, + current: current, + } + svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 33, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + + sub, reused, err := svc.assignSubscriptionWithReuse(context.Background(), &AssignSubscriptionInput{ + UserID: 31, GroupID: 33, ValidityDays: 5, Notes: "renewed", + }) + + require.NoError(t, err) + require.True(t, reused) + require.Equal(t, 1, repo.lockReads) + require.Equal(t, current, repo.current) + require.Equal(t, SubscriptionStatusSuspended, sub.Status) + require.Equal(t, current.ExpiresAt, sub.ExpiresAt) + require.Equal(t, current.Notes, sub.Notes) +} diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go index e4ed45ec45..43ea5b0d10 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) @@ -24,6 +25,8 @@ type resetQuotaUserSubRepoStub struct { resetDailyErr error resetWeeklyErr error resetMonthlyErr error + dailyStart time.Time + periodicStart time.Time } func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) { @@ -34,10 +37,12 @@ func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserS return &cp, nil } -func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, windowStart time.Time) error { +func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error { r.resetDailyCalled = resetDaily r.resetWeeklyCalled = resetWeekly r.resetMonthlyCalled = resetMonthly + r.dailyStart = dailyStart + r.periodicStart = periodicStart if resetDaily && r.resetDailyErr != nil { return r.resetDailyErr } @@ -52,15 +57,15 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64 } if resetDaily { r.sub.DailyUsageUSD = 0 - r.sub.DailyWindowStart = &windowStart + r.sub.DailyWindowStart = &dailyStart } if resetWeekly { r.sub.WeeklyUsageUSD = 0 - r.sub.WeeklyWindowStart = &windowStart + r.sub.WeeklyWindowStart = &periodicStart } if resetMonthly { r.sub.MonthlyUsageUSD = 0 - r.sub.MonthlyWindowStart = &windowStart + r.sub.MonthlyWindowStart = &periodicStart } return nil } @@ -93,6 +98,8 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20}, } svc := newResetQuotaSvc(stub) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + svc.now = func() time.Time { return resetAt } result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false) @@ -101,6 +108,11 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage") require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage") require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") + // 手动重置后日窗口锚定当天 0 点(保持 0 点刷新节奏),周窗口锚定重置时刻。 + require.Equal(t, timezone.StartOfDay(resetAt), stub.dailyStart) + require.Equal(t, resetAt, stub.periodicStart) + require.Equal(t, timezone.StartOfDay(resetAt), *result.DailyWindowStart) + require.Equal(t, resetAt, *result.WeeklyWindowStart) } func TestAdminResetQuota_ResetDailyOnly(t *testing.T) { @@ -203,6 +215,30 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) { require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage") } +func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + stub := &resetQuotaUserSubRepoStub{ + sub: &UserSubscription{ + ID: 10, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + }, + } + svc := newResetQuotaSvc(stub) + svc.now = func() time.Time { return resetAt } + + result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true) + + require.NoError(t, err) + require.Equal(t, resetAt, *result.MonthlyWindowStart) + boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour)) + require.True(t, ok) + require.Equal(t, resetAt.Add(30*24*time.Hour), boundary) +} + func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) { dbErr := errors.New("db error") stub := &resetQuotaUserSubRepoStub{ diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index fbe99bf906..7d716b7a8e 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/dgraph-io/ristretto" "golang.org/x/sync/singleflight" ) @@ -55,6 +56,7 @@ type SubscriptionService struct { subCacheJitter int // 抖动百分比 maintenanceQueue *SubscriptionMaintenanceQueue + now func() time.Time } // NewSubscriptionService 创建订阅服务 @@ -64,6 +66,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript userSubRepo: userSubRepo, billingCacheService: billingCacheService, entClient: entClient, + now: time.Now, } svc.initSubCache(cfg) svc.initMaintenanceQueue(cfg) @@ -242,24 +245,7 @@ func (s *SubscriptionService) assignOrExtendSubscription(ctx context.Context, in // 已有订阅,执行续期(在事务中完成所有更新) if existingSub != nil { - now := time.Now() - var newExpiresAt time.Time - - isExpired := !existingSub.ExpiresAt.After(now) - if !isExpired { - // 未过期:从当前过期时间累加 - newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays) - } else { - // 已过期:从当前时间开始计算 - newExpiresAt = now.AddDate(0, 0, validityDays) - } - - // 确保不超过最大过期时间 - if newExpiresAt.After(MaxExpiresAt) { - newExpiresAt = MaxExpiresAt - } - - if err := s.updateExistingSubscriptionTerm(ctx, existingSub, input.Notes, now, newExpiresAt, isExpired); err != nil { + if err := s.updateExistingSubscriptionTerm(ctx, existingSub.ID, validityDays, input.Notes, false); err != nil { return nil, false, err } @@ -303,15 +289,42 @@ func (s *SubscriptionService) maybeInvalidateAssignmentCaches(userID, groupID in func (s *SubscriptionService) updateExistingSubscriptionTerm( ctx context.Context, - existingSub *UserSubscription, + subscriptionID int64, + validityDays int, notes string, - startsAt time.Time, - newExpiresAt time.Time, - isExpired bool, + assignmentSemantics bool, ) error { return s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error { + existingSub, err := s.userSubRepo.GetByIDForUpdate(txCtx, subscriptionID) + if err != nil { + return fmt.Errorf("lock subscription for renewal: %w", err) + } + if assignmentSemantics && existingSub.Status == SubscriptionStatusSuspended { + return nil + } + + now := time.Now() + if s.now != nil { + now = s.now() + } + isExpired := !existingSub.ExpiresAt.After(now) + if assignmentSemantics { + isExpired = existingSub.Status == SubscriptionStatusExpired || + (existingSub.Status != SubscriptionStatusSuspended && !existingSub.ExpiresAt.After(now)) + } + newExpiresAt := existingSub.ExpiresAt.AddDate(0, 0, validityDays) if isExpired { - renewed := renewedSubscriptionTerm(existingSub, notes, startsAt, newExpiresAt) + newExpiresAt = now.AddDate(0, 0, validityDays) + } + if newExpiresAt.After(MaxExpiresAt) { + newExpiresAt = MaxExpiresAt + } + if assignmentSemantics && strings.TrimSpace(existingSub.Notes) == strings.TrimSpace(notes) { + notes = "" + } + + if isExpired { + renewed := renewedSubscriptionTerm(existingSub, notes, now, newExpiresAt) if err := s.userSubRepo.Update(txCtx, renewed); err != nil { return fmt.Errorf("renew expired subscription: %w", err) } @@ -368,13 +381,15 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription { renewed := *existingSub - windowStart := startOfDay(startsAt) + // 日窗口按日历日对齐(0 点刷新);周/月窗口按订阅期限对齐(锚点为新周期起点)。 + dailyWindowStart := timezone.StartOfDay(startsAt) + periodicWindowStart := startsAt renewed.StartsAt = startsAt renewed.ExpiresAt = expiresAt renewed.Status = SubscriptionStatusActive - renewed.DailyWindowStart = &windowStart - renewed.WeeklyWindowStart = &windowStart - renewed.MonthlyWindowStart = &windowStart + renewed.DailyWindowStart = &dailyWindowStart + renewed.WeeklyWindowStart = &periodicWindowStart + renewed.MonthlyWindowStart = &periodicWindowStart renewed.DailyUsageUSD = 0 renewed.WeeklyUsageUSD = 0 renewed.MonthlyUsageUSD = 0 @@ -512,15 +527,7 @@ func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, i if sub.Status == SubscriptionStatusExpired || (sub.Status != SubscriptionStatusSuspended && !sub.ExpiresAt.After(now)) { validityDays := normalizeAssignValidityDays(input.ValidityDays) - newExpiresAt := now.AddDate(0, 0, validityDays) - if newExpiresAt.After(MaxExpiresAt) { - newExpiresAt = MaxExpiresAt - } - renewalNotes := input.Notes - if strings.TrimSpace(sub.Notes) == strings.TrimSpace(input.Notes) { - renewalNotes = "" - } - if err := s.updateExistingSubscriptionTerm(ctx, sub, renewalNotes, now, newExpiresAt, true); err != nil { + if err := s.updateExistingSubscriptionTerm(ctx, sub.ID, validityDays, input.Notes, true); err != nil { return nil, false, err } s.maybeInvalidateAssignmentCaches(input.UserID, input.GroupID, false) @@ -803,20 +810,24 @@ func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, user // normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库) // 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据 func normalizeExpiredWindows(subs []UserSubscription) { + normalizeExpiredWindowsAt(subs, time.Now()) +} + +func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) { for i := range subs { sub := &subs[i] // 日窗口过期:清零展示数据 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyWindowStart = nil sub.DailyUsageUSD = 0 } // 周窗口过期:清零展示数据 - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyWindowStart = nil sub.WeeklyUsageUSD = 0 } // 月窗口过期:清零展示数据 - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyWindowStart = nil sub.MonthlyUsageUSD = 0 } @@ -842,17 +853,20 @@ func startOfDay(t time.Time) time.Time { // CheckAndActivateWindow 检查并激活窗口(首次使用时) func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error { + return s.checkAndActivateWindowAt(ctx, sub, s.now()) +} + +func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error { if sub.IsWindowActivated() { return nil } - // 使用当天零点作为窗口起始时间 - windowStart := startOfDay(time.Now()) - return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart) + // 日窗口锚定当天 0 点(日历日语义);周/月窗口锚定首次使用时刻(期限对齐语义, + // 锚点不得早于 StartsAt,否则最后一个不完整周期会重复发放额度,见 issue #5051)。 + return s.userSubRepo.ActivateWindows(ctx, sub.ID, timezone.StartOfDay(now), now) } // AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows. -// Uses startOfDay(now) as the new window start, matching automatic resets. func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) { if !resetDaily && !resetWeekly && !resetMonthly { return nil, ErrInvalidInput @@ -861,8 +875,10 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI if err != nil { return nil, err } - windowStart := startOfDay(time.Now()) - if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil { + now := s.now() + // 日窗口锚点取当天 0 点:手动重置只清空用量,不改变“每天 0 点刷新”的节奏。 + // 周/月窗口保持锚定重置时刻(期限对齐滚动窗口语义)。 + if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, timezone.StartOfDay(now), now); err != nil { return nil, err } // Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design, @@ -878,12 +894,11 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI // CheckAndResetWindows 检查并重置过期的窗口 func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error { - // 使用当天零点作为新窗口起始时间 - windowStart := startOfDay(time.Now()) + now := s.now() needsInvalidateCache := false - // 日窗口重置(24小时) - if sub.NeedsDailyReset() { + // 日窗口重置(每天 0 点刷新,按日历日对齐) + if windowStart, ok := sub.automaticDailyWindowStartAt(now); ok { expectedWindowStart := sub.DailyWindowStart if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -894,7 +909,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 周窗口重置(7天) - if sub.NeedsWeeklyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok { expectedWindowStart := sub.WeeklyWindowStart if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -905,7 +920,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 月窗口重置(30天) - if sub.NeedsMonthlyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok { expectedWindowStart := sub.MonthlyWindowStart if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -971,6 +986,7 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub // 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。 // 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。 func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) { + now := s.now() // 1. 验证订阅状态 if sub.Status == SubscriptionStatusExpired { return false, ErrSubscriptionExpired @@ -978,21 +994,21 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou if sub.Status == SubscriptionStatusSuspended { return false, ErrSubscriptionSuspended } - if sub.IsExpired() { + if !sub.ExpiresAt.After(now) { return false, ErrSubscriptionExpired } // 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。 // 调用方随后同步推进 DB 窗口,并用回读快照重新校验。 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyUsageUSD = 0 needsMaintenance = true } diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index 3871b72b58..06ae726df9 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -7,12 +7,15 @@ import ( // TempUnschedState 临时不可调度状态 type TempUnschedState struct { - UntilUnix int64 `json:"until_unix"` // 解除时间(Unix 时间戳) - TriggeredAtUnix int64 `json:"triggered_at_unix"` // 触发时间(Unix 时间戳) - StatusCode int `json:"status_code"` // 触发的错误码 - MatchedKeyword string `json:"matched_keyword"` // 匹配的关键词 - RuleIndex int `json:"rule_index"` // 触发的规则索引 - ErrorMessage string `json:"error_message"` // 错误消息 + UntilUnix int64 `json:"until_unix"` // 解除时间(Unix 时间戳) + TriggeredAtUnix int64 `json:"triggered_at_unix"` // 触发时间(Unix 时间戳) + StatusCode int `json:"status_code"` // 触发的错误码 + MatchedKeyword string `json:"matched_keyword"` // 匹配的关键词 + RuleIndex int `json:"rule_index"` // 触发的规则索引 + ErrorMessage string `json:"error_message"` // 错误消息 + TriggerCount int64 `json:"trigger_count,omitempty"` // 本次触发累计命中次数 + TriggerThreshold int `json:"trigger_threshold,omitempty"` // 触发阈值 + TriggerWindowMinutes int `json:"trigger_window_minutes,omitempty"` // 计数窗口(分钟) } // TempUnschedCache 临时不可调度缓存接口 diff --git a/backend/internal/service/tencent_captcha_service.go b/backend/internal/service/tencent_captcha_service.go new file mode 100644 index 0000000000..e7468f4a20 --- /dev/null +++ b/backend/internal/service/tencent_captcha_service.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +var ( + ErrTencentCaptchaVerificationFailed = infraerrors.BadRequest("TENCENT_CAPTCHA_VERIFICATION_FAILED", "tencent captcha verification failed") + ErrTencentCaptchaNotConfigured = infraerrors.ServiceUnavailable("TENCENT_CAPTCHA_NOT_CONFIGURED", "tencent captcha not configured") +) + +type TencentCaptchaProof struct { + Ticket string + Randstr string +} + +type TencentCaptchaCredentials struct { + AppID uint64 + AppSecretKey string + CloudSecretID string + CloudSecretKey string + // Endpoint 服务端票据校验接入点,由地域推导,repository 层直接使用 + Endpoint string +} + +const ( + // TencentCaptchaRegionCN 中国站(cloud.tencent.com);TencentCaptchaRegionINTL 国际站(tencentcloud.com)。 + // 该值同时决定前端加载的 SDK 脚本与服务端校验接入点,两端必须一致: + // 国际站 CaptchaAppId 配国内站 SDK 会被腾讯直接判为「appid 所属地域与实际使用地域不符」。 + TencentCaptchaRegionCN = "cn" + TencentCaptchaRegionINTL = "intl" + + tencentCaptchaEndpointCN = "captcha.tencentcloudapi.com" + tencentCaptchaEndpointINTL = "captcha.intl.tencentcloudapi.com" +) + +// tencentCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国站 +func tencentCaptchaEndpoint(region string) string { + if region == TencentCaptchaRegionINTL { + return tencentCaptchaEndpointINTL + } + return tencentCaptchaEndpointCN +} + +// normalizeTencentCaptchaRegion 非法值一律视为中国站 +func normalizeTencentCaptchaRegion(value string) string { + if value == TencentCaptchaRegionINTL { + return TencentCaptchaRegionINTL + } + return TencentCaptchaRegionCN +} + +type TencentCaptchaVerifyResponse struct { + CaptchaCode int64 + CaptchaMsg string + RequestID string +} + +type TencentCaptchaVerifier interface { + VerifyTicket(context.Context, TencentCaptchaCredentials, TencentCaptchaProof, string) (*TencentCaptchaVerifyResponse, error) +} + +type TencentCaptchaService struct { + settingService *SettingService + verifier TencentCaptchaVerifier +} + +func NewTencentCaptchaService(settingService *SettingService, verifier TencentCaptchaVerifier) *TencentCaptchaService { + return &TencentCaptchaService{settingService: settingService, verifier: verifier} +} + +func (s *TencentCaptchaService) VerifyTicket(ctx context.Context, ticket, randstr, remoteIP string) error { + if s == nil || s.settingService == nil { + return ErrTencentCaptchaNotConfigured + } + providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx) + if err != nil { + logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] failed to read captcha provider settings") + return ErrServiceUnavailable + } + config := providerConfig.Tencent + if !config.Enabled { + return nil + } + return s.VerifyTicketWithConfig(ctx, config, ticket, randstr, remoteIP) +} + +func (s *TencentCaptchaService) VerifyTicketWithConfig(ctx context.Context, config TencentCaptchaConfig, ticket, randstr, remoteIP string) error { + credentials, ok := parseTencentCaptchaCredentials(config) + if !ok || s.verifier == nil { + return ErrTencentCaptchaNotConfigured + } + + proof := TencentCaptchaProof{ + Ticket: strings.TrimSpace(ticket), + Randstr: strings.TrimSpace(randstr), + } + if proof.Ticket == "" || proof.Randstr == "" || strings.HasPrefix(proof.Ticket, "trerror_") { + return ErrTencentCaptchaVerificationFailed + } + + result, err := s.verifier.VerifyTicket(ctx, credentials, proof, remoteIP) + if err != nil { + logger.LegacyPrintf( + "service.tencent_captcha", + "[TencentCaptcha] verification request failed region=%s endpoint=%s error=%v", + normalizeTencentCaptchaRegion(config.Region), + credentials.Endpoint, + err, + ) + return fmt.Errorf("%w: verifier request failed", ErrTencentCaptchaVerificationFailed) + } + if result == nil || result.CaptchaCode != 1 { + if result != nil { + logger.LegacyPrintf( + "service.tencent_captcha", + "[TencentCaptcha] rejected region=%s code=%d message=%q request_id=%q", + normalizeTencentCaptchaRegion(config.Region), + result.CaptchaCode, + result.CaptchaMsg, + result.RequestID, + ) + } else { + logger.LegacyPrintf( + "service.tencent_captcha", + "[TencentCaptcha] rejected region=%s empty_response=true", + normalizeTencentCaptchaRegion(config.Region), + ) + } + return ErrTencentCaptchaVerificationFailed + } + return nil +} + +func parseTencentCaptchaCredentials(config TencentCaptchaConfig) (TencentCaptchaCredentials, bool) { + appID, err := strconv.ParseUint(strings.TrimSpace(config.AppID), 10, 64) + if err != nil || appID == 0 { + return TencentCaptchaCredentials{}, false + } + credentials := TencentCaptchaCredentials{ + AppID: appID, + AppSecretKey: strings.TrimSpace(config.AppSecretKey), + CloudSecretID: strings.TrimSpace(config.CloudSecretID), + CloudSecretKey: strings.TrimSpace(config.CloudSecretKey), + Endpoint: tencentCaptchaEndpoint(config.Region), + } + if credentials.AppSecretKey == "" || credentials.CloudSecretID == "" || credentials.CloudSecretKey == "" { + return TencentCaptchaCredentials{}, false + } + return credentials, true +} diff --git a/backend/internal/service/tencent_captcha_service_test.go b/backend/internal/service/tencent_captcha_service_test.go new file mode 100644 index 0000000000..b67af70545 --- /dev/null +++ b/backend/internal/service/tencent_captcha_service_test.go @@ -0,0 +1,142 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type tencentCaptchaVerifierStub struct { + response *TencentCaptchaVerifyResponse + err error + calls int + proof TencentCaptchaProof + remoteIP string + credentials TencentCaptchaCredentials +} + +func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, credentials TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) { + s.calls++ + s.credentials = credentials + s.proof = proof + s.remoteIP = remoteIP + return s.response, s.err +} + +func newTencentCaptchaTestService(verifier TencentCaptchaVerifier) *TencentCaptchaService { + return newTencentCaptchaTestServiceWithRegion(verifier, "") +} + +func newTencentCaptchaTestServiceWithRegion(verifier TencentCaptchaVerifier, region string) *TencentCaptchaService { + values := map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + } + if region != "" { + values[SettingKeyTencentCaptchaRegion] = region + } + settings := NewSettingService(&settingPublicRepoStub{values: values}, &config.Config{}) + return NewTencentCaptchaService(settings, verifier) +} + +// 站点决定服务端票据校验接入点:国际站账号的密钥在国内站接入点上无法通过鉴权, +// 因此这条映射一旦错位,国际站验证码会整体失效。 +func TestTencentCaptchaServiceRoutesVerifyEndpointByRegion(t *testing.T) { + cases := []struct { + name string + region string + wantEndpoint string + }{ + {"未配置回落中国站", "", "captcha.tencentcloudapi.com"}, + {"中国站", TencentCaptchaRegionCN, "captcha.tencentcloudapi.com"}, + {"国际站", TencentCaptchaRegionINTL, "captcha.intl.tencentcloudapi.com"}, + {"非法值回落中国站", "sgp", "captcha.tencentcloudapi.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newTencentCaptchaTestServiceWithRegion(verifier, tc.region) + + require.NoError(t, svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10")) + require.Equal(t, tc.wantEndpoint, verifier.credentials.Endpoint) + }) + } +} + +func TestTencentCaptchaServiceAcceptsCaptchaCodeOne(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.NoError(t, err) + require.Equal(t, 1, verifier.calls) + require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof) + require.Equal(t, "203.0.113.10", verifier.remoteIP) +} + +func TestTencentCaptchaServiceRejectsDisasterRecoveryTicketWithoutCallingVerifier(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "trerror_1001_123456789_1", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) + require.Zero(t, verifier.calls) +} + +func TestTencentCaptchaServiceRejectsEveryNonOneCode(t *testing.T) { + for _, code := range []int64{0, 7, 8, 9, 15, 16, 21, 100} { + t.Run(string(rune(code)), func(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: code}} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) + }) + } +} + +func TestTencentCaptchaServiceFailsClosedOnVerifierError(t *testing.T) { + verifier := &tencentCaptchaVerifierStub{err: errors.New("sdk unavailable")} + svc := newTencentCaptchaTestService(verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.Error(t, err) + require.ErrorIs(t, err, ErrTencentCaptchaVerificationFailed) +} + +func TestTencentCaptchaServiceRejectsIncompleteConfiguration(t *testing.T) { + settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + }}, &config.Config{}) + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := NewTencentCaptchaService(settings, verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrTencentCaptchaNotConfigured) + require.Zero(t, verifier.calls) +} + +func TestTencentCaptchaServiceFailsClosedOnSettingsReadError(t *testing.T) { + settings := NewSettingService(&settingPublicRepoStub{err: errors.New("settings unavailable")}, &config.Config{}) + verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}} + svc := NewTencentCaptchaService(settings, verifier) + + err := svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10") + + require.ErrorIs(t, err, ErrServiceUnavailable) + require.Zero(t, verifier.calls) +} diff --git a/backend/internal/service/tencent_captcha_settings_test.go b/backend/internal/service/tencent_captcha_settings_test.go new file mode 100644 index 0000000000..f67b16e8ed --- /dev/null +++ b/backend/internal/service/tencent_captcha_settings_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestSettingService_ParseSettingsMasksTencentCaptchaCredentials(t *testing.T) { + svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }}, &config.Config{}) + + settings, err := svc.GetAllSettings(context.Background()) + + require.NoError(t, err) + require.True(t, settings.TencentCaptchaEnabled) + require.Equal(t, "123456789", settings.TencentCaptchaAppID) + require.True(t, settings.TencentCaptchaAppSecretKeyConfigured) + require.True(t, settings.TencentCaptchaCloudSecretIDConfigured) + require.True(t, settings.TencentCaptchaCloudSecretKeyConfigured) + require.Equal(t, "app-secret", settings.TencentCaptchaAppSecretKey) + require.Equal(t, "cloud-secret-id", settings.TencentCaptchaCloudSecretID) + require.Equal(t, "cloud-secret-key", settings.TencentCaptchaCloudSecretKey) +} + +func TestSettingService_GetPublicSettingsExposesOnlyTencentCaptchaAppID(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + SettingKeyTencentCaptchaRegion: TencentCaptchaRegionINTL, + }}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.TencentCaptchaEnabled) + require.Equal(t, "123456789", settings.TencentCaptchaAppID) + // 站点必须原样公开下发:前端据此决定加载哪个站点的 SDK 脚本与构造函数形态。 + require.Equal(t, TencentCaptchaRegionINTL, settings.TencentCaptchaRegion) + + raw, err := json.Marshal(settings) + require.NoError(t, err) + require.NotContains(t, string(raw), "app-secret") + require.NotContains(t, string(raw), "cloud-secret-id") + require.NotContains(t, string(raw), "cloud-secret-key") +} + +func TestSettingService_GetTencentCaptchaConfig(t *testing.T) { + repo := &settingPublicRepoStub{values: map[string]string{ + SettingKeyTencentCaptchaEnabled: "true", + SettingKeyTencentCaptchaAppID: "123456789", + SettingKeyTencentCaptchaAppSecretKey: "app-secret", + SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id", + SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key", + }} + svc := NewSettingService(repo, &config.Config{}) + + got := svc.GetTencentCaptchaConfig(context.Background()) + + require.Equal(t, TencentCaptchaConfig{ + Enabled: true, + AppID: "123456789", + AppSecretKey: "app-secret", + CloudSecretID: "cloud-secret-id", + CloudSecretKey: "cloud-secret-key", + // 未配置站点时回落中国站,保持存量部署行为不变 + Region: TencentCaptchaRegionCN, + }, got) +} diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 31d2e19909..bbf1e8655e 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -1200,6 +1200,10 @@ func (s *TokenRefreshService) postRefreshActions(ctx context.Context, account *A s.ensureOpenAIPrivacy(ctx, account) // Antigravity OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则调用 setUserSettings s.ensureAntigravityPrivacy(ctx, account) + // Grok: clear soft reauth flag after a successful credential refresh. + if account != nil && account.Platform == PlatformGrok && accountGrokNeedsReauth(account) { + clearGrokNeedsReauthExtra(ctx, s.accountRepo, account.ID) + } } func (s *TokenRefreshService) postRefreshStateSyncWithCleanup(parent context.Context, account *Account) { diff --git a/backend/internal/service/token_refresh_service_candidates_test.go b/backend/internal/service/token_refresh_service_candidates_test.go index ac4e3cf4f0..285caa3b91 100644 --- a/backend/internal/service/token_refresh_service_candidates_test.go +++ b/backend/internal/service/token_refresh_service_candidates_test.go @@ -51,6 +51,7 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont } } if options.ActiveOnly && account.Status != StatusActive || + !account.Schedulable || account.Type != AccountTypeOAuth || !platformAllowed || options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" || @@ -122,6 +123,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, }, { @@ -129,6 +131,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{}, }, { @@ -143,6 +146,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, TempUnschedulableUntil: &future, TempUnschedulableReason: "token refresh retry exhausted: network timeout", @@ -152,6 +156,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: "other", Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, }, { @@ -159,11 +164,20 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, Extra: map[string]any{"privacy_mode": AntigravityPrivacySet}, TempUnschedulableUntil: &future, TempUnschedulableReason: "OAuth 401: unauthorized", }, + { + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: false, + Credentials: map[string]any{"refresh_token": "permanently-rejected-token"}, + }, }, } svc := &TokenRefreshService{ diff --git a/backend/internal/service/turnstile_service.go b/backend/internal/service/turnstile_service.go index e454e2f1e1..94ce5d69b9 100644 --- a/backend/internal/service/turnstile_service.go +++ b/backend/internal/service/turnstile_service.go @@ -53,6 +53,10 @@ func (s *TurnstileService) VerifyToken(ctx context.Context, token string, remote // 获取 Secret Key secretKey := s.settingService.GetTurnstileSecretKey(ctx) + return s.VerifyTokenWithSecret(ctx, secretKey, token, remoteIP) +} + +func (s *TurnstileService) VerifyTokenWithSecret(ctx context.Context, secretKey, token, remoteIP string) error { if secretKey == "" { logger.LegacyPrintf("service.turnstile", "%s", "[Turnstile] Secret key not configured") return ErrTurnstileNotConfigured @@ -65,6 +69,9 @@ func (s *TurnstileService) VerifyToken(ctx context.Context, token string, remote } logger.LegacyPrintf("service.turnstile", "[Turnstile] Verifying token for IP: %s", remoteIP) + if s == nil || s.verifier == nil { + return ErrTurnstileNotConfigured + } result, err := s.verifier.VerifyToken(ctx, secretKey, token, remoteIP) if err != nil { logger.LegacyPrintf("service.turnstile", "[Turnstile] Request failed: %v", err) diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index d5c28e68b6..d66cd905dc 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -8,9 +8,11 @@ import ( "errors" "fmt" "io" + "log/slog" "math" "math/rand/v2" "net/http" + "net/url" "sort" "strconv" "strings" @@ -27,8 +29,9 @@ import ( const ( // These values live in accounts.extra so PR2 does not require a schema migration. - UpstreamBillingProbeExtraKey = "upstream_billing_probe" - UpstreamBillingProbeEnabledExtraKey = "upstream_billing_probe_enabled" + UpstreamBillingProbeExtraKey = "upstream_billing_probe" + UpstreamBillingProbeEnabledExtraKey = "upstream_billing_probe_enabled" + UpstreamBillingRateSyncEnabledExtraKey = "upstream_billing_rate_sync_enabled" upstreamBillingProbeDefaultIntervalMinutes = 30 upstreamBillingProbeMinIntervalMinutes = 5 @@ -39,6 +42,11 @@ const ( upstreamBillingProbeMaxPerCycle = 20 upstreamBillingProbeConcurrency = 4 upstreamBillingProbeMaxDelay = 24 * time.Hour + // unsupported 账号的重探间隔倍数:上游不是 sub2api 中转就不会突然长出 + // /v1/sub2api/billing,按常规 interval 重排只会持续占满每周期 + // upstreamBillingProbeMaxPerCycle 个名额。 + upstreamBillingProbeUnsupportedDelayFactor = 8 + upstreamBillingProbeAccountRateScale = 10000.0 upstreamBillingProbeLeaderLockKey = "upstream:billing:probe:leader" upstreamBillingProbeLeaderLockTTL = 2 * time.Minute ) @@ -46,16 +54,39 @@ const ( // UpstreamBillingProbeMaxBatchSize limits one manual batch and one runner cycle. const UpstreamBillingProbeMaxBatchSize = upstreamBillingProbeMaxPerCycle +// upstreamBillingRateSyncMaxMultiplier bounds the value the automatic +// write-back may push into accounts.rate_multiplier. +// +// No other code path bounds that column from above — admins may type any +// non-negative number and the only ceiling is the DECIMAL(10,4) column itself +// (999999.9999). That ceiling is meaningless as a guard: rate_multiplier +// scales the per-request account cost that feeds quota_used, so a single +// declared 999999 would exhaust any account quota on the first request and +// poison cost reporting. 100 is picked as a deliberately generous bound: it is +// two orders of magnitude above the 1.0 default and far above any plausible +// upstream resale markup, so no legitimate declaration is rejected while an +// absurd or hostile one cannot reach the quota control plane unattended. +// It only constrains the automatic path; manual edits keep their old range. +const upstreamBillingRateSyncMaxMultiplier = 100.0 + var ( ErrUpstreamBillingProbeUnavailable = infraerrors.ServiceUnavailable( "UPSTREAM_BILLING_PROBE_UNAVAILABLE", "upstream billing probe is unavailable", ) ErrUpstreamBillingProbeAccountInvalid = infraerrors.BadRequest( - "UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID", "account is not an OpenAI API key account", + "UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID", "account is not an API key account", ) ErrUpstreamBillingProbeIdentityChanged = infraerrors.Conflict( "UPSTREAM_BILLING_PROBE_IDENTITY_CHANGED", "account identity changed during upstream billing probe; retry the probe", ) + ErrUpstreamBillingRateSyncBulkConflict = infraerrors.Conflict( + "UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT", + "account rate multiplier cannot be changed in bulk while upstream billing rate sync is enabled", + ) + ErrUpstreamBillingRateSyncConflict = infraerrors.Conflict( + "UPSTREAM_BILLING_RATE_SYNC_CONFLICT", + "account rate multiplier cannot be changed while upstream billing rate sync is enabled", + ) ) const ( @@ -82,6 +113,12 @@ type UpstreamBillingProbeSnapshot struct { FailureCount int `json:"failure_count,omitempty"` HTTPStatus int `json:"http_status,omitempty"` LastError string `json:"last_error,omitempty"` + // SyncedRateMultiplier records the value this probe wrote into + // accounts.rate_multiplier. It is only set when the account opted into rate + // sync and the declared value passed the write-back range check, so the + // stored snapshot always answers "did this probe move the account rate, and + // to what" without a separate history table. + SyncedRateMultiplier *float64 `json:"synced_rate_multiplier,omitempty"` } // UpstreamBillingProbeResult is returned by manual probe endpoints. @@ -192,7 +229,7 @@ type UpstreamBillingProbeService struct { } type upstreamBillingProbeSnapshotWriter interface { - UpdateUpstreamBillingProbeSnapshot(context.Context, *Account, *UpstreamBillingProbeSnapshot) error + UpdateUpstreamBillingProbeSnapshot(context.Context, *Account, *UpstreamBillingProbeSnapshot, *float64) error } type upstreamBillingProbeDueAccountLister interface { @@ -541,9 +578,11 @@ func (s *UpstreamBillingProbeService) SetAccountEnabled(ctx context.Context, acc if !isUpstreamBillingProbeAccount(account) { return ErrUpstreamBillingProbeAccountInvalid } - return s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ - UpstreamBillingProbeEnabledExtraKey: enabled, - }) + updates := map[string]any{UpstreamBillingProbeEnabledExtraKey: enabled} + if !enabled { + updates[UpstreamBillingRateSyncEnabledExtraKey] = false + } + return s.accountRepo.UpdateExtra(ctx, accountID, updates) } func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, account *Account, intervalMinutes int) (*UpstreamBillingProbeSnapshot, error) { @@ -551,13 +590,24 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac if s.accountTestService == nil || s.accountTestService.httpUpstream == nil { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "transport_unavailable", 0) } - apiKey := account.GetOpenAIApiKey() + // 平台放宽后取数直读 credentials:所有 API-key 平台的密钥与自定义上游 + // 统一存放在 credentials.api_key / credentials.base_url。 + apiKey := account.GetCredential("api_key") if apiKey == "" { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } - baseURL := account.GetOpenAIBaseURL() - if baseURL == "" { - baseURL = "https://api.openai.com" + baseURL := account.GetCredential("base_url") + if account.Platform == PlatformOpenAI { + if baseURL == "" { + // 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。 + baseURL = "https://api.openai.com" + } + } else if upstreamBillingProbeTargetIsOfficialAPI(baseURL) { + // 其他平台 base_url 为空或指向官方 API 根域(前端创建时会把空值 + // 填成官方默认域,且提供 us-east-1.api.x.ai 等官方区域预设)⇒ + // 必无 /v1/sub2api/billing;不发请求,直接记 unsupported,避免 + // 拿账号 Key 周期性请求官方域的不存在路径。 + return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "unsupported", 0) } normalizedBaseURL, err := s.accountTestService.validateUpstreamBaseURL(baseURL) if err != nil { @@ -580,7 +630,12 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac if err != nil { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "request_build_failed", 0) } - reqCtx := WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI) + // OpenAI 账号保持官方 openai 传输画像;其他平台探测走默认画像。 + profile := HTTPUpstreamProfileDefault + if account.Platform == PlatformOpenAI { + profile = HTTPUpstreamProfileOpenAI + } + reqCtx := WithHTTPUpstreamProfile(req.Context(), profile) req = req.WithContext(WithHTTPUpstreamRedirectsDisabled(reqCtx)) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) @@ -623,9 +678,39 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, 0)), HTTPStatus: resp.StatusCode, } - if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + // 账号级值域与精度只在真要写回时才有影响:只观察上游声明、未开启同步的 + // 账号不因声明值不适配 accounts.rate_multiplier 而被记成探测失败并进入 + // 指数退避——探测本身成功了,原始声明照常存进快照供展示。 + var syncRate *float64 + previousRate := account.BillingRateMultiplier() + if upstreamBillingRateSyncEnabled(account) { + if value, valid := upstreamBillingProbeSyncRate(data); valid { + syncRate = &value + snapshot.SyncedRateMultiplier = &value + } else { + declared, _ := resolveAccountExtraNumber(data, "resolved_rate_multiplier") + slog.Warn("upstream_billing_rate_sync_rejected", + "source", "upstream_billing_probe", + "account_id", account.ID, + "declared_resolved_rate_multiplier", declared, + "max_rate_multiplier", upstreamBillingRateSyncMaxMultiplier, + "current_rate_multiplier", previousRate, + ) + } + } + if err := s.updateSnapshot(ctx, account, snapshot, syncRate); err != nil { return nil, err } + if syncRate != nil { + // 写回是后台任务的裸 SQL,不经过管理端路由,因此不会产生 audit_logs 行。 + // old_rate_multiplier 是本次探测开始时读到的值(写回的 CAS 不比对该列)。 + slog.Info("upstream_billing_rate_sync_applied", + "source", "upstream_billing_probe", + "account_id", account.ID, + "old_rate_multiplier", previousRate, + "new_rate_multiplier", *syncRate, + ) + } return snapshot, nil } @@ -644,13 +729,15 @@ func (s *UpstreamBillingProbeService) persistProbeFailure( failureCount = previous.FailureCount + 1 } status := UpstreamBillingProbeStatusFailed + delay := nextProbeDelay(intervalMinutes, retryAfterDuration) if reason == "unsupported" { status = UpstreamBillingProbeStatusUnsupported + delay = unsupportedProbeDelay(intervalMinutes, retryAfterDuration) } snapshot := &UpstreamBillingProbeSnapshot{ Status: status, LastAttemptAt: now, - NextProbeAt: now.Add(nextProbeDelay(intervalMinutes, retryAfterDuration)), + NextProbeAt: now.Add(delay), FailureCount: failureCount, HTTPStatus: statusCode, LastError: reason, @@ -663,18 +750,23 @@ func (s *UpstreamBillingProbeService) persistProbeFailure( snapshot.FreshUntil = probeTimePtr(previous.ReceivedAt.Add(2 * time.Duration(intervalMinutes) * time.Minute)) } } - if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + if err := s.updateSnapshot(ctx, account, snapshot, nil); err != nil { return nil, err } return snapshot, nil } -func (s *UpstreamBillingProbeService) updateSnapshot(ctx context.Context, account *Account, snapshot *UpstreamBillingProbeSnapshot) error { +func (s *UpstreamBillingProbeService) updateSnapshot( + ctx context.Context, + account *Account, + snapshot *UpstreamBillingProbeSnapshot, + rateMultiplier *float64, +) error { writer, ok := s.accountRepo.(upstreamBillingProbeSnapshotWriter) if !ok { return ErrUpstreamBillingProbeUnavailable } - return writer.UpdateUpstreamBillingProbeSnapshot(ctx, account, snapshot) + return writer.UpdateUpstreamBillingProbeSnapshot(ctx, account, snapshot, rateMultiplier) } func parseUpstreamBillingProbeResponse(body []byte) (map[string]any, error) { @@ -776,6 +868,37 @@ func upstreamBillingRateAt(data map[string]any, now time.Time) (float64, bool) { return base, true } +// upstreamBillingProbeSyncRate converts the declared multiplier into the value +// the automatic write-back may store in accounts.rate_multiplier, at the +// precision that column supports (DECIMAL(10,4)). +// +// It reads resolved_rate_multiplier, not effective_rate_multiplier: the +// effective value folds in the peak coefficient that happened to apply at the +// instant of the probe, so writing it would freeze one probe cycle's peak (or +// off-peak) factor into a static column, while display and scheduling +// recompute the peak factor for the current time through upstreamBillingRateAt. +// +// The accepted range is deliberately narrower than the column: +// - 0 is rejected. accountCost multiplies the request cost by this value, so +// an upstream-declared 0 would stop quota_used from ever growing and every +// admin-configured account quota and cost alert would silently stop +// working. Admins may still set 0 by hand; only the automatic path refuses. +// - anything above upstreamBillingRateSyncMaxMultiplier is rejected. +// +// A rejected declaration leaves the current multiplier untouched; the probe +// still records an OK snapshot carrying the raw declaration for display. +func upstreamBillingProbeSyncRate(data map[string]any) (float64, bool) { + value, ok := resolveAccountExtraNumber(data, "resolved_rate_multiplier") + if !ok || math.IsNaN(value) || math.IsInf(value, 0) { + return 0, false + } + rounded := math.Round(value*upstreamBillingProbeAccountRateScale) / upstreamBillingProbeAccountRateScale + if rounded <= 0 || rounded > upstreamBillingRateSyncMaxMultiplier { + return 0, false + } + return rounded, true +} + func upstreamBillingPeakMultiplierAt(data map[string]any, now time.Time) (float64, bool) { peakEnabled, ok := data["peak_rate_enabled"].(bool) if !ok { @@ -840,8 +963,73 @@ func decodeUpstreamBillingProbeSnapshot(extra map[string]any) *UpstreamBillingPr return &snapshot } +// 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. +// 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 +// a base_url plus a static api_key, but it is deliberately left out of the +// current supported set. New antigravity relay accounts are created with +// type=apikey by the admin form, so only pre-existing type=upstream rows +// cannot turn the probe on. +func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { + if accountType != AccountTypeAPIKey { + return false + } + switch platform { + case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok: + return true + default: + return false + } +} + func isUpstreamBillingProbeAccount(account *Account) bool { - return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey + return account != nil && IsUpstreamBillingProbeIdentity(account.Platform, account.Type) +} + +// upstreamBillingProbeOfficialAPIDomains lists the root domains of official +// provider APIs. The create form fills empty base_url values with official +// defaults (and offers official regional presets like us-east-1.api.x.ai), +// so probing them would send the account key to an official API path that +// cannot exist. Matching is by registrable root domain — exact host or any +// subdomain, after stripping the port and a trailing DNS dot — because no +// third-party sub2api relay can live under these domains, while custom +// relays (the only targets that can answer /v1/sub2api/billing) always do +// probe. OpenAI-platform accounts never reach this check: they keep the +// upstream-official behavior of probing api.openai.com. +// 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. +var upstreamBillingProbeOfficialAPIDomains = []string{ + "anthropic.com", + "googleapis.com", + "x.ai", + "grok.com", + "openai.com", + "ollama.com", +} + +func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return true + } + parsed, err := url.Parse(baseURL) + if err != nil { + return false + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "" { + return true + } + for _, domain := range upstreamBillingProbeOfficialAPIDomains { + if host == domain || strings.HasSuffix(host, "."+domain) { + return true + } + } + return false } func upstreamBillingProbeEnabled(account *Account) bool { @@ -852,6 +1040,18 @@ func upstreamBillingProbeEnabled(account *Account) bool { return ok && enabled } +// upstreamBillingRateSyncEnabled is the probe-side pre-filter deciding whether +// a rate is even proposed for write-back. It is a necessary condition, not the +// authority: the repository CAS re-checks both switches against the row it +// updates, so a switch flipped between load and write can never sneak a rate in. +func upstreamBillingRateSyncEnabled(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + enabled, ok := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool) + return ok && enabled && upstreamBillingProbeEnabled(account) +} + func (s *UpstreamBillingProbeService) currentTime() time.Time { if s != nil && s.now != nil { return s.now() @@ -885,6 +1085,23 @@ func nextProbeDelay(intervalMinutes int, retryAfterDuration time.Duration) time. return interval } +// unsupportedProbeDelay 拉长 unsupported 账号的重探间隔,让无效候选自然退出 +// 热队列,不再和真正接入 sub2api 的中转账号抢每周期的探测名额。 +// 仍按 upstreamBillingProbeMaxDelay 封顶,保证上游后来接入 sub2api 时最迟一天 +// 内会被重新发现;base 本身已达上限(例如 Retry-After 明确要求更久)时原样返回, +// 不缩短上游指令。 +func unsupportedProbeDelay(intervalMinutes int, retryAfterDuration time.Duration) time.Duration { + base := nextProbeDelay(intervalMinutes, retryAfterDuration) + if base >= upstreamBillingProbeMaxDelay { + return base + } + stretched := base * upstreamBillingProbeUnsupportedDelayFactor + if stretched > upstreamBillingProbeMaxDelay { + return upstreamBillingProbeMaxDelay + } + return stretched +} + func retryAfter(header http.Header, now time.Time) time.Duration { value := strings.TrimSpace(header.Get("Retry-After")) if value == "" { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go new file mode 100644 index 0000000000..018f2b7198 --- /dev/null +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -0,0 +1,216 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 探测资格:/v1/sub2api/billing 是 key 级端点,五个 +// 受支持平台的 API-key 账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 +func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { + for _, platform := range []string{ + PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, + } { + require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) + require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) + } + require.False(t, IsUpstreamBillingProbeIdentity(PlatformOpenAI, AccountTypeOAuth)) + require.False(t, IsUpstreamBillingProbeIdentity(PlatformGrok, AccountTypeOAuth)) + require.False(t, IsUpstreamBillingProbeIdentity(PlatformAnthropic, AccountTypeBedrock)) + require.False(t, IsUpstreamBillingProbeIdentity("", AccountTypeAPIKey)) + require.False(t, IsUpstreamBillingProbeIdentity("future-platform", AccountTypeAPIKey)) + require.False(t, isUpstreamBillingProbeAccount(nil)) +} + +func upstreamBillingProbeValidBody() io.ReadCloser { + return io.NopCloser(strings.NewReader(`{ + "object":"sub2api.key_billing", + "schema_version":1, + "billing_scope":"token", + "group_rate_multiplier":0.02, + "resolved_rate_multiplier":0.02, + "peak_rate_enabled":false, + "effective_rate_multiplier":0.02, + "observed_at":"2026-07-26T01:00:00Z" + }`)) +} + +func TestUpstreamBillingProbeGrokAccountPersistsSnapshot(t *testing.T) { + account := &Account{ + ID: 151, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 2, + Credentials: map[string]any{ + "api_key": "sk-grok-relay", + "base_url": "https://relay.example/v1", + }, + } + 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{}) + fixedNow := time.Date(2026, time.July, 26, 2, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return fixedNow } + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Equal(t, 0.02, snapshot.Data["resolved_rate_multiplier"]) + require.Equal(t, "https://relay.example/v1/sub2api/billing", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer sk-grok-relay", upstream.lastReq.Header.Get("Authorization")) + // 非 OpenAI 平台探测使用默认传输画像。 + require.Equal(t, HTTPUpstreamProfileDefault, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) + + persisted := decodeUpstreamBillingProbeSnapshot(account.Extra) + require.NotNil(t, persisted) + require.Equal(t, UpstreamBillingProbeStatusOK, persisted.Status) +} + +// 非 OpenAI 平台没有自定义 base_url 时,上游是各自官方 API,必无 +// /v1/sub2api/billing:不发请求,直接落 unsupported。 +func TestUpstreamBillingProbeNonOpenAIWithoutBaseURLIsUnsupportedWithoutRequest(t *testing.T) { + account := &Account{ + ID: 152, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-grok-official"}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status) + require.Nil(t, upstream.lastReq) +} + +// 前端创建 API-key 账号时会把空 base_url 填成平台官方默认域;官方 API 同样 +// 必无 /v1/sub2api/billing,不发请求直接 unsupported。 +// 显式端口、DNS 尾点、官方区域子域(前端 Grok 预设含 us-east-1.api.x.ai 等) +// 与跨平台官方域都不能绕过。 +func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *testing.T) { + cases := []struct { + platform string + baseURL string + }{ + {PlatformAnthropic, "https://api.anthropic.com"}, + {PlatformAnthropic, "https://api.anthropic.com:443"}, + {PlatformAnthropic, "https://api.anthropic.com./"}, + {PlatformAnthropic, "HTTPS://API.ANTHROPIC.COM/"}, + {PlatformGemini, "https://generativelanguage.googleapis.com"}, + {PlatformAntigravity, "https://cloudcode-pa.googleapis.com"}, + {PlatformGrok, "https://api.x.ai/v1"}, + {PlatformGrok, "https://us-east-1.api.x.ai/v1"}, + {PlatformGrok, "https://eu-west-1.api.x.ai/v1"}, + {PlatformGrok, "https://cli-chat-proxy.grok.com/v1"}, + // 跨平台官方域同样必无该端点,一并拦截。 + {PlatformAnthropic, "https://api.x.ai/v1"}, + {PlatformGrok, "https://api.openai.com"}, + // Ollama Cloud 是本仓一等支持配置(platform openai/anthropic + + // base_url https://ollama.com/v1),同为官方 API,不能拿 Key 去空探。 + {PlatformAnthropic, "https://ollama.com/v1"}, + {PlatformAnthropic, "https://ollama.com"}, + {PlatformAnthropic, "https://www.ollama.com/v1"}, + } + for i, tc := range cases { + account := &Account{ + ID: int64(200 + i), + Platform: tc.platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-official", "base_url": tc.baseURL}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err, "%s %s", tc.platform, tc.baseURL) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status, "%s %s", tc.platform, tc.baseURL) + require.Nil(t, upstream.lastReq, "%s %s", tc.platform, tc.baseURL) + } +} + +// 官方域判定按规范化 host 的根域后缀匹配:端口、尾点、区域子域都拦; +// 自定义中转与相似但不同的域照常探测。 +func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.x.ai")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.anthropic.com:8443")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://us-west-2.api.x.ai/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://generativelanguage.googleapis.com.")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://x.ai")) + // openai 官方域在全集内;openai 平台账号不经过本判定(行为级测试钉死照探)。 + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.openai.com")) + // Ollama Cloud 官方域及其子域(Ollama Cloud 账号的 base_url 允许带 www.)。 + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://ollama.com/v1")) + 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")) + // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://anthropic.com.evil.example")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.relay-station.example")) + 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")) +} + +// OpenAI 语义保持不变:无自定义 base 时仍探官方域,且沿用 openai 传输画像。 +func TestUpstreamBillingProbeOpenAIDefaultBaseURLPreserved(t *testing.T) { + account := &Account{ + ID: 17, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-openai"}, + } + 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{}) + + _, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, "https://api.openai.com/v1/sub2api/billing", upstream.lastReq.URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) +} + +func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) { + grokAPIKey := &Account{ + ID: 151, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk", "base_url": "https://relay.example"}, + } + grokOAuth := &Account{ID: 152, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive} + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + grokAPIKey.ID: grokAPIKey, + grokOAuth.ID: grokOAuth, + }} + svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + require.NoError(t, svc.SetAccountEnabled(context.Background(), grokAPIKey.ID, true)) + require.Equal(t, true, repo.accounts[grokAPIKey.ID].Extra[UpstreamBillingProbeEnabledExtraKey]) + + err := svc.SetAccountEnabled(context.Background(), grokOAuth.ID, true) + require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid) +} diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go index eecd866a90..e032e9aa16 100644 --- a/backend/internal/service/upstream_billing_probe_test.go +++ b/backend/internal/service/upstream_billing_probe_test.go @@ -110,7 +110,12 @@ func (r *upstreamBillingProbeAccountRepo) UpdateExtra(_ context.Context, id int6 return nil } -func (r *upstreamBillingProbeAccountRepo) UpdateUpstreamBillingProbeSnapshot(_ context.Context, expected *Account, snapshot *UpstreamBillingProbeSnapshot) error { +func (r *upstreamBillingProbeAccountRepo) UpdateUpstreamBillingProbeSnapshot( + _ context.Context, + expected *Account, + snapshot *UpstreamBillingProbeSnapshot, + rateMultiplier *float64, +) error { r.mu.Lock() defer r.mu.Unlock() account := r.accounts[expected.ID] @@ -121,6 +126,12 @@ func (r *upstreamBillingProbeAccountRepo) UpdateUpstreamBillingProbeSnapshot(_ c account.Extra = make(map[string]any) } account.Extra[UpstreamBillingProbeExtraKey] = snapshot + if snapshot.Status == UpstreamBillingProbeStatusOK && + rateMultiplier != nil && + upstreamBillingRateSyncEnabled(account) { + value := *rateMultiplier + account.RateMultiplier = &value + } return nil } @@ -259,6 +270,7 @@ func TestUpstreamBillingProbeSettingsDefaultsAndValidation(t *testing.T) { } func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { + initialRate := 0.25 account := &Account{ ID: 17, Platform: PlatformOpenAI, @@ -269,6 +281,11 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { "api_key": "sk-sensitive", "base_url": "https://upstream.example/v1", }, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + }, + RateMultiplier: &initialRate, } repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} upstream := &httpUpstreamRecorder{resp: &http.Response{ @@ -307,6 +324,12 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { require.Equal(t, fixedNow.Add(time.Hour), *snapshot.FreshUntil) require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute))) require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute))) + // 写回的是不含高峰因子的 resolved 倍率(0.6),不是探测那一刻含高峰的 + // effective 倍率(0.9)——否则一个探测周期的峰值会被冻结进静态列。 + require.NotNil(t, account.RateMultiplier) + require.Equal(t, 0.6, *account.RateMultiplier) + require.NotNil(t, snapshot.SyncedRateMultiplier) + require.Equal(t, 0.6, *snapshot.SyncedRateMultiplier) require.Equal(t, "https://upstream.example/v1/sub2api/billing", upstream.lastReq.URL.String()) require.Equal(t, http.MethodGet, upstream.lastReq.Method) require.Equal(t, "Bearer sk-sensitive", upstream.lastReq.Header.Get("Authorization")) @@ -317,6 +340,217 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { require.Equal(t, snapshot.Status, persisted.Status) } +func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) { + for _, platform := range []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + } { + t.Run(platform, func(t *testing.T) { + initialRate := 0.25 + account := &Account{ + ID: 17, + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + RateMultiplier: &initialRate, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "base_url": "https://upstream.example", + }, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: true, + }, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.NotNil(t, account.RateMultiplier) + require.Equal(t, 0.8, *account.RateMultiplier) + }) + } +} + +func TestUpstreamBillingProbeOnlyDoesNotChangeAccountRate(t *testing.T) { + initialRate := 0.25 + account := &Account{ + ID: 18, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + RateMultiplier: &initialRate, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "base_url": "https://upstream.example", + }, + Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.NotNil(t, account.RateMultiplier) + require.Equal(t, initialRate, *account.RateMultiplier) + require.Contains(t, account.Extra, UpstreamBillingProbeExtraKey) +} + +func TestUpstreamBillingProbeSyncRateRangeAndPrecision(t *testing.T) { + tests := []struct { + name string + value float64 + want float64 + ok bool + }{ + {name: "round to four decimals", value: 0.07654, want: 0.0765, ok: true}, + {name: "maximum", value: upstreamBillingRateSyncMaxMultiplier, want: upstreamBillingRateSyncMaxMultiplier, ok: true}, + // 0 会让 accountCost 恒为 0,账号配额与成本告警全部静默失效, + // 自动写回一律拒绝(管理员手工设 0 仍然允许)。 + {name: "zero is rejected", value: 0, ok: false}, + {name: "positive below database precision rounds to zero", value: 0.00001, ok: false}, + {name: "just above the write-back ceiling", value: 100.0001, ok: false}, + {name: "column ceiling is far above the write-back ceiling", value: 999999.9999, ok: false}, + {name: "negative", value: -1, ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := upstreamBillingProbeSyncRate(map[string]any{"resolved_rate_multiplier": tt.value}) + require.Equal(t, tt.ok, ok) + if tt.ok { + require.Equal(t, tt.want, got) + } + }) + } +} + +// 只读取 resolved(时间无关的基准倍率):effective 含探测那一刻的高峰系数, +// 写回它会把一个探测周期的峰值/谷值冻结进静态列。 +func TestUpstreamBillingProbeSyncRateIgnoresEffectiveRate(t *testing.T) { + got, ok := upstreamBillingProbeSyncRate(map[string]any{ + "resolved_rate_multiplier": 0.6, + "effective_rate_multiplier": 0.9, + }) + require.True(t, ok) + require.Equal(t, 0.6, got) + + _, ok = upstreamBillingProbeSyncRate(map[string]any{"effective_rate_multiplier": 0.9}) + require.False(t, ok) +} + +// 上游声明超出自动写回值域时保持原倍率,但探测本身是成功的: +// 快照照常记 ok,不累计 failure_count、不进入退避。 +func TestUpstreamBillingProbeKeepsRateWhenDeclarationOutOfSyncRange(t *testing.T) { + for _, tt := range []struct { + name string + declared string + }{ + {name: "zero", declared: "0"}, + {name: "above ceiling", declared: "1000"}, + } { + t.Run(tt.name, func(t *testing.T) { + initialRate := 0.25 + account := &Account{ + ID: 21, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + RateMultiplier: &initialRate, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "base_url": "https://upstream.example", + }, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingRateSyncEnabledExtraKey: 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: io.NopCloser(strings.NewReader(fmt.Sprintf(`{ + "object":"sub2api.key_billing", + "schema_version":1, + "billing_scope":"token", + "group_rate_multiplier":%[1]s, + "resolved_rate_multiplier":%[1]s, + "peak_rate_enabled":false, + "effective_rate_multiplier":%[1]s, + "observed_at":"2026-07-13T01:00:00Z" + }`, tt.declared))), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Zero(t, snapshot.FailureCount) + require.Nil(t, snapshot.SyncedRateMultiplier) + require.NotNil(t, account.RateMultiplier) + require.Equal(t, initialRate, *account.RateMultiplier) + // 原始声明仍进快照供展示。 + require.Equal(t, snapshot.Data["resolved_rate_multiplier"], snapshot.Data["effective_rate_multiplier"]) + }) + } +} + +// 未开启同步的账号只观察上游声明:声明值不适配 accounts.rate_multiplier +// 不得被记成探测失败(否则会累计 failure_count 并进入指数退避)。 +func TestUpstreamBillingProbeWithoutSyncIgnoresUnusableDeclaredRate(t *testing.T) { + account := &Account{ + ID: 22, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "base_url": "https://upstream.example", + }, + 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: io.NopCloser(strings.NewReader(`{ + "object":"sub2api.key_billing", + "schema_version":1, + "billing_scope":"token", + "group_rate_multiplier":0, + "resolved_rate_multiplier":0, + "peak_rate_enabled":false, + "effective_rate_multiplier":0, + "observed_at":"2026-07-13T01:00:00Z" + }`)), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Zero(t, snapshot.FailureCount) + require.Empty(t, snapshot.LastError) + require.Nil(t, snapshot.SyncedRateMultiplier) + require.Equal(t, float64(0), snapshot.Data["resolved_rate_multiplier"]) + require.Nil(t, account.RateMultiplier) +} + func TestUpstreamBillingProbeRejectsMissingRequiredMultiplier(t *testing.T) { _, err := parseUpstreamBillingProbeResponse([]byte(`{ "object":"sub2api.key_billing", @@ -456,6 +690,7 @@ func TestUpstreamBillingRateAtHandlesDST(t *testing.T) { func TestUpstreamBillingProbeFailurePreservesLastSuccessAndRetryAfter(t *testing.T) { receivedAt := time.Date(2026, time.July, 12, 12, 0, 0, 0, time.UTC) + initialRate := 0.35 previous := &UpstreamBillingProbeSnapshot{ Status: UpstreamBillingProbeStatusOK, Data: map[string]any{"effective_rate_multiplier": 0.5}, @@ -463,13 +698,17 @@ func TestUpstreamBillingProbeFailurePreservesLastSuccessAndRetryAfter(t *testing FailureCount: 1, } account := &Account{ - ID: 18, - Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, - Status: StatusActive, - Concurrency: 1, - Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"}, - Extra: map[string]any{UpstreamBillingProbeExtraKey: previous}, + ID: 18, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + RateMultiplier: &initialRate, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://upstream.example"}, + Extra: map[string]any{ + UpstreamBillingProbeEnabledExtraKey: true, + UpstreamBillingProbeExtraKey: previous, + }, } repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} upstream := &httpUpstreamRecorder{resp: &http.Response{ @@ -492,6 +731,8 @@ func TestUpstreamBillingProbeFailurePreservesLastSuccessAndRetryAfter(t *testing require.Equal(t, "http_error", snapshot.LastError) require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(4*time.Hour))) require.NotContains(t, snapshot.LastError, "do not persist") + require.NotNil(t, account.RateMultiplier) + require.Equal(t, initialRate, *account.RateMultiplier) } func TestUpstreamBillingProbeRetryAfterIsNotShortened(t *testing.T) { @@ -499,6 +740,69 @@ func TestUpstreamBillingProbeRetryAfterIsNotShortened(t *testing.T) { require.Equal(t, 48*time.Hour, delay) } +// unsupported 的重探间隔明显长于普通失败,但始终有上界:上游后来接入 sub2api +// 时最迟一天内会被重新发现,且不会缩短上游 Retry-After 指令。 +func TestUpstreamBillingProbeUnsupportedDelayIsStretchedAndBounded(t *testing.T) { + // 默认 30 分钟 interval:普通失败 24~36 分钟,unsupported 为其 8 倍。 + stretched := unsupportedProbeDelay(30, 0) + require.Greater(t, stretched, 36*time.Minute) + require.GreaterOrEqual(t, stretched, 192*time.Minute) + require.LessOrEqual(t, stretched, 288*time.Minute) + + // 永不超过封顶值,因此 unsupported 账号不会被永久排除在重探之外。 + require.LessOrEqual(t, unsupportedProbeDelay(upstreamBillingProbeMaxIntervalMinutes, 0), upstreamBillingProbeMaxDelay) + require.Positive(t, unsupportedProbeDelay(upstreamBillingProbeMinIntervalMinutes, 0)) + + // Retry-After 更长时原样保留,不被封顶缩短;更短时至少不早于该指令。 + require.Equal(t, 48*time.Hour, unsupportedProbeDelay(30, 48*time.Hour)) + require.GreaterOrEqual(t, unsupportedProbeDelay(30, time.Hour), time.Hour) +} + +// 加长退避只把 unsupported 账号移出周期性热队列,手动探测不受影响。 +func TestUpstreamBillingProbeUnsupportedBackoffDefersRunnerButNotManualProbe(t *testing.T) { + // Ollama Cloud 形态:官方域,不发请求直接落 unsupported。 + account := &Account{ + ID: 31, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-ollama", "base_url": "https://ollama.com/v1"}, + Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &upstreamBillingProbeHTTPStub{} + settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{ + SettingKeyUpstreamBillingProbeSettings: `{"enabled":true,"interval_minutes":30}`, + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, settingsRepo) + start := time.Date(2026, time.July, 26, 2, 0, 0, 0, time.UTC) + now := start + svc.now = func() time.Time { return now } + + require.NoError(t, svc.RunDue(context.Background())) + first := decodeUpstreamBillingProbeSnapshot(account.Extra) + require.NotNil(t, first) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, first.Status) + require.Equal(t, start, first.LastAttemptAt) + require.False(t, first.NextProbeAt.Before(start.Add(192*time.Minute))) + require.Zero(t, upstream.calls.Load()) + + // 一个普通失败早就该重探的时间点(远超 36 分钟),runner 仍跳过该账号。 + now = start.Add(90 * time.Minute) + require.NoError(t, svc.RunDue(context.Background())) + deferred := decodeUpstreamBillingProbeSnapshot(account.Extra) + require.NotNil(t, deferred) + require.Equal(t, first.LastAttemptAt, deferred.LastAttemptAt) + + // 手动探测无视退避窗口,管理员随时可以重试。 + manual, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, manual.Status) + require.Equal(t, now, manual.LastAttemptAt) + require.Equal(t, 2, manual.FailureCount) +} + func TestUpstreamBillingProbeEmptyResponseIsPersistedAsFailure(t *testing.T) { account := &Account{ ID: 21, @@ -548,18 +852,23 @@ func TestUpstreamBillingProbeUnsupportedAndAccountToggle(t *testing.T) { require.NoError(t, svc.SetAccountEnabled(context.Background(), account.ID, true)) require.Equal(t, true, account.Extra[UpstreamBillingProbeEnabledExtraKey]) + account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true snapshot, err := svc.ProbeAccount(context.Background(), account.ID) require.NoError(t, err) require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status) require.Equal(t, "unsupported", snapshot.LastError) - require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute))) - require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute))) + // unsupported 走加长退避:默认 30 分钟 interval ⇒ (24~36) * 8 分钟。 + require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(192*time.Minute))) + require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(288*time.Minute))) snapshot, err = svc.ProbeAccount(context.Background(), account.ID) require.NoError(t, err) require.Equal(t, 2, snapshot.FailureCount) - require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(24*time.Minute))) - require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(36*time.Minute))) + require.False(t, snapshot.NextProbeAt.Before(fixedNow.Add(192*time.Minute))) + require.False(t, snapshot.NextProbeAt.After(fixedNow.Add(288*time.Minute))) + require.NoError(t, svc.SetAccountEnabled(context.Background(), account.ID, false)) + require.Equal(t, false, account.Extra[UpstreamBillingProbeEnabledExtraKey]) + require.Equal(t, false, account.Extra[UpstreamBillingRateSyncEnabledExtraKey]) invalid := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth} repo.accounts[invalid.ID] = invalid @@ -598,10 +907,14 @@ func TestUpstreamBillingProbeRunnerIsBoundedAndManualProbeIgnoresSwitches(t *tes require.Equal(t, int64(20), upstream.calls.Load()) accounts[25].Extra[UpstreamBillingProbeEnabledExtraKey] = false + manualRate := 0.25 + accounts[25].RateMultiplier = &manualRate snapshot, err := svc.ProbeAccount(context.Background(), 25) require.NoError(t, err) require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) require.Equal(t, int64(21), upstream.calls.Load()) + require.NotNil(t, accounts[25].RateMultiplier) + require.Equal(t, manualRate, *accounts[25].RateMultiplier) } func TestUpstreamBillingProbeRunnerRechecksEnabledAfterDueSelection(t *testing.T) { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 393d57870c..9a4e4311b0 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -193,7 +193,11 @@ func (s *AccountTestService) buildGrokUpstreamModelsRequest(ctx context.Context, if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } - validatedBaseURL, err := validator(account.GetGrokBaseURL()) + baseURL := account.GetGrokBaseURL() + if s.settingService != nil { + baseURL = s.settingService.ResolveGrokBaseURL(ctx, account) + } + validatedBaseURL, err := validator(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 1c8920e71c..031d09b574 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -260,7 +260,7 @@ func TestBuildUpstreamModelsRequestSupportsGrokOAuth(t *testing.T) { require.Equal(t, "Bearer oauth-access-token", req.Header.Get("Authorization")) require.Equal(t, grokCLIVersion, req.Header.Get("X-Grok-Client-Version")) require.Equal(t, "interactive", req.Header.Get("X-Grok-Client-Mode")) - require.Equal(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) + require.Equal(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) require.Equal(t, "grok-user-id", req.Header.Get("X-UserID")) require.Equal(t, "grok-user@example.com", req.Header.Get("X-Email")) require.NotContains(t, req.Header.Get("Authorization"), "oauth-refresh-token") diff --git a/backend/internal/service/upstream_path_guard.go b/backend/internal/service/upstream_path_guard.go new file mode 100644 index 0000000000..e976ab9be7 --- /dev/null +++ b/backend/internal/service/upstream_path_guard.go @@ -0,0 +1,95 @@ +package service + +import ( + "fmt" + "strings" +) + +// 上游 URL 路径片段护栏。 +// +// 网关会把若干客户端可控的字符串(Responses 子路径、Gemini 模型名)拼进上游请求 +// 的 URL path。约定:这些字符串只允许"结构惰性"的字符,即拼进去之后不可能改变 +// 上游请求的路径结构;不符合的一律拒绝。 +// +// 实现上是**闭集允许清单(默认拒绝)**,请勿改成"逐个拒绝已知的坏字符":后者要求 +// 穷举,漏一项就失效;默认拒绝则相反,清单外的写法天然被挡住,无需跟着改代码。 +// +// 另外注意:到达业务代码的 c.Request.URL.Path 已经是百分号解码后的结果,因此校验 +// 必须放在这一层,不能假设上游看到的路径与客户端书写形式一致。 +// +// 这里只校验、不改写:把不合规输入自动修正成合规路径,会让上游收到与客户端意图 +// 不同的请求,也会掩盖调用方的错误。 + +const ( + // maxUpstreamPathSegmentLen 单个路径片段长度上限。真实的 response id、模型名 + // 都远短于此,留足余量只为拒绝异常输入。 + maxUpstreamPathSegmentLen = 128 + // maxUpstreamPathSegments 后缀允许的片段数上限(如 /{id}/cancel 为 2)。 + maxUpstreamPathSegments = 8 +) + +// isSafeUpstreamPathSegmentByte 是闭集允许清单:只放行 `\w`(即 [A-Za-z0-9_]) +// 以及真实取值必需的 `-` 与 `.`。其余字符(含控制字符与非 ASCII)一律拒绝。 +func isSafeUpstreamPathSegmentByte(b byte) bool { + switch { + case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9': + return true + case b == '_', b == '-', b == '.': + return true + default: + return false + } +} + +// isSafeUpstreamPathSegment 判断 segment 能否原样拼进上游 URL 的一个 path 片段。 +// +// 允许清单里唯一在路径语义中有特殊含义的字符是 `.`,因此额外要求片段不能只由点 +// 组成——各类实现对这种片段的解释并不一致,直接拒绝最省心。 +func isSafeUpstreamPathSegment(segment string) bool { + if segment == "" || len(segment) > maxUpstreamPathSegmentLen { + return false + } + dotsOnly := true + for i := 0; i < len(segment); i++ { + if !isSafeUpstreamPathSegmentByte(segment[i]) { + return false + } + if segment[i] != '.' { + dotsOnly = false + } + } + return !dotsOnly +} + +// sanitizedUpstreamPathSuffix 校验 "/a/b" 形态的路径后缀。 +// ok=false 表示后缀不可转发,调用方必须拒绝请求,而不是降级成空后缀——否则 +// /responses/compact 之类的请求语义会被静默改写。空后缀合法,表示"没有子路径"。 +func sanitizedUpstreamPathSuffix(raw string) (string, bool) { + suffix := strings.TrimSpace(raw) + if suffix == "" { + return "", true + } + if !strings.HasPrefix(suffix, "/") { + return "", false + } + segments := strings.Split(strings.TrimPrefix(suffix, "/"), "/") + if len(segments) > maxUpstreamPathSegments { + return "", false + } + for _, segment := range segments { + if !isSafeUpstreamPathSegment(segment) { + return "", false + } + } + return suffix, true +} + +// validateUpstreamPathSegment 供 URL 构造点使用:不合规的片段直接变成显式错误, +// 不再继续构造与发出上游请求。 +func validateUpstreamPathSegment(kind, segment string) error { + if isSafeUpstreamPathSegment(strings.TrimSpace(segment)) { + return nil + } + // 不回显原始输入,避免把它写进日志与错误响应。 + return fmt.Errorf("invalid %s for upstream url path", kind) +} diff --git a/backend/internal/service/upstream_path_guard_test.go b/backend/internal/service/upstream_path_guard_test.go new file mode 100644 index 0000000000..3ea88da285 --- /dev/null +++ b/backend/internal/service/upstream_path_guard_test.go @@ -0,0 +1,160 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSanitizedUpstreamPathSuffixRejectsNonConformingSegments(t *testing.T) { + // 到达业务代码的 URL.Path 已是百分号解码后的结果,因此用例按解码后的形态书写。 + rejected := []string{ + "/..", + "/../..", + "/../../x/y", + "/./compact", + "/compact/..", + `/..\..\x`, + `/compact\..`, + "/?a=b", + "/compact?a=b", + "/compact#frag", + "/compact%2f..", + "/100%", + "//double", + "/compact//detail", + "/compact/", + "/ compact", + "/compact\x00", + "/compact\nX-Injected: 1", + "/模型", + "compact", + "/a:b", + "/a;b", + "/a,b", + "/a=b", + "/a&b", + // 允许清单是闭集:`\w` + `-` + `.` 以外的字符一律拒绝, + // 不依赖任何"已知坏字符"清单。 + "/a~b", + "/a@b", + "/a+b", + "/a|b", + "/a*b", + "/a$b", + "/a(b)", + "/a'b", + "/a\"b", + "/a upstreamResponseModelMaxLength { + model = string(runes[:upstreamResponseModelMaxLength]) + } + return model +} + +func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) { + model := firstValidTrimmedGJSONModel(payload, "response.model", "model") + o.Observe(model, isUpstreamResponseModelTerminalEvent(eventType)) +} + +func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) { + model := firstValidTrimmedGJSONModel(payload, "message.model", "model") + o.Observe(model, false) +} + +func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) { + model := firstValidTrimmedGJSONModel( + payload, + "modelVersion", + "response.modelVersion", + "response.response.modelVersion", + ) + // Gemini streaming has no universal terminal event carrying modelVersion; + // treating each declaration as terminal retains the latest chunk. + o.Observe(model, true) +} + +func (o *upstreamResponseModelObserver) Model() string { + if o == nil { + return "" + } + if o.terminal != "" { + return o.terminal + } + return o.first +} + +func (o *upstreamResponseModelObserver) Conflict() bool { + return o != nil && o.conflict +} + +func beginUpstreamResponseModelObservation(c *gin.Context) *upstreamResponseModelObserver { + observer := &upstreamResponseModelObserver{} + if c != nil { + c.Set(upstreamResponseModelObserverContextKey, observer) + } + return observer +} + +func upstreamResponseModelObserverFromContext(c *gin.Context) *upstreamResponseModelObserver { + if c == nil { + return nil + } + value, ok := c.Get(upstreamResponseModelObserverContextKey) + if !ok { + return nil + } + observer, _ := value.(*upstreamResponseModelObserver) + return observer +} + +func observedUpstreamResponseModel(c *gin.Context) string { + return upstreamResponseModelObserverFromContext(c).Model() +} + +func observedUpstreamResponseModelConflict(c *gin.Context) bool { + return upstreamResponseModelObserverFromContext(c).Conflict() +} + +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()) + observer.ObserveOpenAI(payload, eventType) + }) +} + +func firstValidTrimmedGJSONModel(payload []byte, paths ...string) string { + if len(payload) == 0 { + return "" + } + for _, path := range paths { + value := gjson.GetBytes(payload, path) + if !value.Exists() || value.Type != gjson.String { + continue + } + if model := strings.TrimSpace(value.String()); model != "" { + // 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. + if !gjson.ValidBytes(payload) { + return "" + } + return model + } + } + return "" +} + +func isUpstreamResponseModelTerminalEvent(eventType string) bool { + switch strings.TrimSpace(eventType) { + case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": + return true + default: + return false + } +} + +func upstreamModelMismatch(sentModel, responseModel string) *bool { + responseModel = strings.TrimSpace(responseModel) + if responseModel == "" { + return nil + } + sentModel = strings.TrimSpace(sentModel) + mismatch := sentModel == "" || !strings.EqualFold(sentModel, responseModel) + return &mismatch +} + +func upstreamSentModel(requestedModel, upstreamModel string) string { + sentModel := strings.TrimSpace(upstreamModel) + if sentModel == "" { + sentModel = strings.TrimSpace(requestedModel) + } + return sentModel +} diff --git a/backend/internal/service/upstream_response_model_bench_test.go b/backend/internal/service/upstream_response_model_bench_test.go new file mode 100644 index 0000000000..93d6854f72 --- /dev/null +++ b/backend/internal/service/upstream_response_model_bench_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +var ( + upstreamResponseModelBenchmarkSink string + + upstreamResponseModelBenchmarkTerminal = []byte(`{"type":"response.completed","response":{"id":"resp_123","model":"gpt-5.5-2026-04-23"},"usage":{"input_tokens":128,"output_tokens":64}}`) + upstreamResponseModelBenchmarkDelta = []byte(`{"type":"response.output_text.delta","delta":"hello"}`) + upstreamResponseModelBenchmarkWrapper = []byte(`{"response":{"response":{"modelVersion":"gemini-3-pro","candidates":[]}}}`) +) + +func BenchmarkUpstreamResponseModelOpenAI(b *testing.B) { + tests := []struct { + name string + payload []byte + }{ + {name: "terminal_with_model", payload: upstreamResponseModelBenchmarkTerminal}, + {name: "delta_without_model", payload: upstreamResponseModelBenchmarkDelta}, + } + + for _, tt := range tests { + b.Run(tt.name, func(b *testing.B) { + b.Run("legacy", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + upstreamResponseModelBenchmarkSink = benchmarkLegacyOpenAIModel(tt.payload) + } + }) + b.Run("optimized", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel(tt.payload, "response.model", "model") + } + }) + }) + } +} + +func BenchmarkUpstreamResponseModelAntigravityWrapper(b *testing.B) { + b.Run("legacy", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + upstreamResponseModelBenchmarkSink = benchmarkLegacyWrappedGeminiModel(upstreamResponseModelBenchmarkWrapper) + } + }) + b.Run("optimized", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel( + upstreamResponseModelBenchmarkWrapper, + "modelVersion", + "response.modelVersion", + "response.response.modelVersion", + ) + } + }) +} + +func benchmarkLegacyOpenAIModel(payload []byte) string { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return "" + } + return benchmarkFirstTrimmedGJSONModel( + gjson.GetBytes(payload, "response.model"), + gjson.GetBytes(payload, "model"), + ) +} + +func benchmarkLegacyWrappedGeminiModel(payload []byte) string { + if inner := gjson.GetBytes(payload, "response"); inner.Exists() { + payload = []byte(inner.Raw) + } + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return "" + } + return benchmarkFirstTrimmedGJSONModel( + gjson.GetBytes(payload, "modelVersion"), + gjson.GetBytes(payload, "response.modelVersion"), + ) +} + +func benchmarkFirstTrimmedGJSONModel(values ...gjson.Result) string { + for _, value := range values { + if !value.Exists() || value.Type != gjson.String { + continue + } + if model := strings.TrimSpace(value.String()); model != "" { + return model + } + } + return "" +} diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go new file mode 100644 index 0000000000..a831e74e65 --- /dev/null +++ b/backend/internal/service/upstream_response_model_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestUpstreamResponseModelObserverTerminalWinsAndRecordsConflict(t *testing.T) { + observer := &upstreamResponseModelObserver{} + + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5"}}`), "response.created") + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.4"}}`), "response.completed") + + require.Equal(t, "gpt-5.4", observer.Model()) + require.True(t, observer.Conflict()) +} + +func TestUpstreamResponseModelObserverSupportsAnthropicAndGeminiShapes(t *testing.T) { + t.Run("anthropic", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"model":"claude-sonnet-4-20250514"}}`)) + require.Equal(t, "claude-sonnet-4-20250514", observer.Model()) + }) + + t.Run("gemini outer and nested", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveGemini([]byte(`{"response":{"modelVersion":"gemini-2.5-pro"}}`)) + observer.ObserveGemini([]byte(`{"modelVersion":"gemini-2.5-pro-latest"}`)) + require.Equal(t, "gemini-2.5-pro-latest", observer.Model()) + require.True(t, observer.Conflict()) + }) +} + +func TestUpstreamResponseModelObservationAttemptReset(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + + first := beginUpstreamResponseModelObservation(c) + first.Observe("failed-attempt-model", false) + second := beginUpstreamResponseModelObservation(c) + second.Observe("successful-attempt-model", false) + + require.Equal(t, "successful-attempt-model", observedUpstreamResponseModel(c)) + require.False(t, observedUpstreamResponseModelConflict(c)) +} + +func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing.T) { + require.Nil(t, upstreamModelMismatch("gpt-5.5", "")) + + matched := upstreamModelMismatch("gpt-5.5", "GPT-5.5") + require.NotNil(t, matched) + require.False(t, *matched) + + mismatched := upstreamModelMismatch("gpt-5.5", "gpt-5.4") + require.NotNil(t, mismatched) + require.True(t, *mismatched) +} + +func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n") + + require.Equal(t, "gpt-5.4", observer.Model()) + require.False(t, observer.Conflict()) +} + +func TestObserveAntigravityGeminiSSELineReadsWrapperModelWithoutUnwrap(t *testing.T) { + tests := []struct { + name string + payload string + want string + }{ + { + name: "top-level sibling", + payload: `{"modelVersion":"gemini-3-pro","response":{"candidates":[]}}`, + want: "gemini-3-pro", + }, + { + name: "single wrapper", + payload: `{"response":{"modelVersion":"gemini-3-pro","candidates":[]}}`, + want: "gemini-3-pro", + }, + { + name: "nested response after one wrapper", + payload: `{"response":{"response":{"modelVersion":"gemini-3-pro","candidates":[]}}}`, + want: "gemini-3-pro", + }, + { + name: "outer declaration takes precedence", + payload: `{"modelVersion":"gemini-outer","response":{"modelVersion":"gemini-inner","candidates":[]}}`, + want: "gemini-outer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + beginUpstreamResponseModelObservation(c) + + svc := &AntigravityGatewayService{} + svc.observeAntigravityGeminiSSELine(c, "data: "+tt.payload) + + require.Equal(t, tt.want, observedUpstreamResponseModel(c)) + require.False(t, observedUpstreamResponseModelConflict(c)) + }) + } +} + +func TestUpstreamResponseModelObserverRejectsMalformedJSONWithModelField(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"response":{"model":"gpt-5.4"}`), "response.completed") + + require.Empty(t, observer.Model()) +} + +func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.Observe(" "+strings.Repeat("模", upstreamResponseModelMaxLength+1)+" ", false) + + require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength) +} diff --git a/backend/internal/service/usage_billing.go b/backend/internal/service/usage_billing.go index 8d52c92d26..e058ecb1b0 100644 --- a/backend/internal/service/usage_billing.go +++ b/backend/internal/service/usage_billing.go @@ -6,7 +6,10 @@ import ( "encoding/hex" "errors" "fmt" + "math" "strings" + + "github.com/shopspring/decimal" ) var ErrUsageBillingRequestIDRequired = errors.New("usage billing request_id is required") @@ -49,6 +52,54 @@ func (c *UsageBillingCommand) Normalize() { if strings.TrimSpace(c.RequestFingerprint) == "" { c.RequestFingerprint = buildUsageBillingFingerprint(c) } + // 量化必须在指纹计算之后:指纹是请求幂等键,保持由原始金额派生可以避免 + // 升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict。 + c.quantizeMonetaryFields() +} + +// UsageBillingMonetaryScale 是所有计费金额的规范小数位数, +// 对齐 users.balance / api_keys.quota_used 的 NUMERIC(20,8)。 +const UsageBillingMonetaryScale = 8 + +// quantizeMonetaryFields 把命令中的金额统一量化到 NUMERIC(20,8)。 +// +// 不量化时,同一笔 ActualCost 会在两条方向相反的 SQL 上被 PostgreSQL 分别舍入: +// +// balance = balance - $1 // 存剩余额度,舍入的是「减法结果」 +// quota_used = quota_used + $1 // 存累计用量,舍入的是「加法结果」 +// +// PostgreSQL 对 NUMERIC 采用 half-away-from-zero。当金额在第 9 位出现 half 边界 +// (例:10 输入 token × 0.00000125 + 5 输出 token × 0.00001000,再乘分组倍率 +// 1.25 = 0.000078125)时: +// +// balance: 10000 - 0.000078125 = 9999.999921875 → 9999.99992188(delta 0.00007812) +// quota_used: 0 + 0.000078125 = 0.000078125 → 0.00007813(delta 0.00007813) +// +// 两个 delta 相差 1e-8,且方向相反——余额少扣、Key 配额多记,随请求量线性累积, +// 使余额、API Key 配额与用量记录无法精确对账(需要 epsilon 比较才能勉强吻合)。 +// +// 在参数进入 SQL 之前量化一次,两条语句就都拿到已经落在 8 位刻度上的同一个金额, +// 存储阶段不再发生任何舍入,delta 精确相等。 +func (c *UsageBillingCommand) quantizeMonetaryFields() { + c.BalanceCost = QuantizeUsageBillingAmount(c.BalanceCost) + c.SubscriptionCost = QuantizeUsageBillingAmount(c.SubscriptionCost) + c.APIKeyQuotaCost = QuantizeUsageBillingAmount(c.APIKeyQuotaCost) + c.APIKeyRateLimitCost = QuantizeUsageBillingAmount(c.APIKeyRateLimitCost) + c.AccountQuotaCost = QuantizeUsageBillingAmount(c.AccountQuotaCost) +} + +// QuantizeUsageBillingAmount 把金额舍入到 UsageBillingMonetaryScale 位小数, +// 采用与 PostgreSQL NUMERIC 一致的 half-away-from-zero 规则。 +// +// 走 decimal 而不是 math.Round(v*1e8)/1e8:后者在乘除过程中会引入额外的二进制 +// 误差,边界值可能被推到错误的一侧。decimal.NewFromFloat 取 float64 的最短十进制 +// 表示,正是 PostgreSQL 把 float8 参数转成 numeric 时所用的表示。 +func QuantizeUsageBillingAmount(v float64) float64 { + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return v + } + quantized, _ := decimal.NewFromFloat(v).Round(UsageBillingMonetaryScale).Float64() + return quantized } func buildUsageBillingFingerprint(c *UsageBillingCommand) string { diff --git a/backend/internal/service/usage_billing_quantize_test.go b/backend/internal/service/usage_billing_quantize_test.go new file mode 100644 index 0000000000..5f9491a7a2 --- /dev/null +++ b/backend/internal/service/usage_billing_quantize_test.go @@ -0,0 +1,194 @@ +package service + +import ( + "math" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +// decimalPlaces 返回 float64 最短十进制表示的小数位数, +// 即 PostgreSQL 把 float8 参数转成 numeric 时看到的刻度。 +func decimalPlaces(v float64) int32 { + return -decimal.NewFromFloat(v).Exponent() +} + +// 复现 #5229:同一笔 ActualCost 分别流向 +// +// balance = balance - $1 +// quota_used = quota_used + $1 +// +// 两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入「运算结果」。 +// 金额在第 9 位落在 half 边界时,减法与加法会朝相反方向舍入, +// 导致余额 delta 与 Key 配额 delta 相差 1e-8,永远无法精确对账。 +// +// 修复后命令里的金额已经落在 8 位刻度上,存储阶段不再舍入。 +func TestUsageBillingCommandQuantizesBalanceAndQuotaIdentically(t *testing.T) { + // 10 input × 0.00000125 + 5 output × 0.00001000 = 0.0000625 + // 0.0000625 × 1.25(分组倍率) = 0.000078125 + const actualCost = 0.000078125 + + cmd := &UsageBillingCommand{ + RequestID: "req-5229", + UserID: 1, + APIKeyID: 2, + AccountID: 3, + BalanceCost: actualCost, + APIKeyQuotaCost: actualCost, + } + cmd.Normalize() + + require.Equal(t, cmd.BalanceCost, cmd.APIKeyQuotaCost, + "余额扣减与 API Key 配额累加必须使用同一个规范金额") + require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale), + "金额超过 NUMERIC(20,8) 刻度时 PostgreSQL 仍会在存储阶段舍入") +} + +// 第 9 位 half 边界的表驱动覆盖。 +func TestQuantizeUsageBillingAmountBoundaries(t *testing.T) { + cases := []struct { + name string + in float64 + }{ + {"below_half", 0.000078120}, + {"just_below_half", 0.000078124}, + {"exact_half", 0.000078125}, + {"just_above_half", 0.000078126}, + {"above_half", 0.000078130}, + {"long_tail", 0.0000781234567}, + {"already_quantized", 0.00007813}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := QuantizeUsageBillingAmount(tc.in) + + require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale)) + + want, _ := decimal.NewFromFloat(tc.in).Round(UsageBillingMonetaryScale).Float64() + require.Equal(t, want, got) + + // 量化误差不得超过半个刻度(5e-9)。 + require.LessOrEqual(t, math.Abs(got-tc.in), 5e-9) + }) + } +} + +// 累积对账:重复应用同一笔金额,余额侧与配额侧的总量必须精确相等, +// 不允许依赖 epsilon 比较。 +func TestQuantizedAmountsReconcileExactlyOverManyApplications(t *testing.T) { + const actualCost = 0.000078125 + + cmd := &UsageBillingCommand{ + RequestID: "req-5229-bulk", + UserID: 1, + APIKeyID: 2, + AccountID: 3, + BalanceCost: actualCost, + SubscriptionCost: 0, + APIKeyQuotaCost: actualCost, + APIKeyRateLimitCost: actualCost, + } + cmd.Normalize() + + unit := decimal.NewFromFloat(cmd.BalanceCost) + for _, n := range []int64{1, 10, 100, 1000} { + total := unit.Mul(decimal.NewFromInt(n)) + + balance := decimal.NewFromInt(10000).Sub(total) + quotaUsed := total + + // NUMERIC(20,8) 存储不改变任何一侧的值。 + require.True(t, balance.Equal(balance.Round(UsageBillingMonetaryScale)), + "n=%d 余额结果超出 NUMERIC(20,8) 刻度", n) + require.True(t, quotaUsed.Equal(quotaUsed.Round(UsageBillingMonetaryScale)), + "n=%d 配额结果超出 NUMERIC(20,8) 刻度", n) + + balanceDelta := decimal.NewFromInt(10000).Sub(balance) + require.True(t, balanceDelta.Equal(quotaUsed), + "n=%d 余额 delta 与配额 delta 不相等: %s vs %s", n, balanceDelta, quotaUsed) + } +} + +// 所有金额字段都要量化,避免订阅用量 / 账号配额留在未规范化的刻度上。 +func TestNormalizeQuantizesEveryMonetaryField(t *testing.T) { + const raw = 0.0000781234567 + + cmd := &UsageBillingCommand{ + RequestID: "req-5229-fields", + UserID: 1, + APIKeyID: 2, + AccountID: 3, + BalanceCost: raw, + SubscriptionCost: raw, + APIKeyQuotaCost: raw, + APIKeyRateLimitCost: raw, + AccountQuotaCost: raw, + } + cmd.Normalize() + + for name, got := range map[string]float64{ + "BalanceCost": cmd.BalanceCost, + "SubscriptionCost": cmd.SubscriptionCost, + "APIKeyQuotaCost": cmd.APIKeyQuotaCost, + "APIKeyRateLimitCost": cmd.APIKeyRateLimitCost, + "AccountQuotaCost": cmd.AccountQuotaCost, + } { + require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale), name) + } +} + +// 指纹是请求幂等键,必须仍由原始金额派生: +// 若量化发生在指纹之前,升级前后同一 request_id 的重试会算出不同指纹, +// 被误判为 fingerprint conflict。 +func TestNormalizeKeepsFingerprintDerivedFromRawAmounts(t *testing.T) { + const raw = 0.000078125 + + newCmd := func() *UsageBillingCommand { + return &UsageBillingCommand{ + RequestID: "req-5229-fp", + UserID: 1, + APIKeyID: 2, + AccountID: 3, + BalanceCost: raw, + APIKeyQuotaCost: raw, + } + } + + cmd := newCmd() + expected := buildUsageBillingFingerprint(newCmd()) + + cmd.Normalize() + require.Equal(t, expected, cmd.RequestFingerprint) +} + +// 显式设置的指纹不被覆盖,且金额仍会被量化。 +func TestNormalizePreservesExplicitFingerprint(t *testing.T) { + cmd := &UsageBillingCommand{ + RequestID: "req-5229-explicit", + RequestFingerprint: "preset-fingerprint", + BalanceCost: 0.0000781234567, + } + cmd.Normalize() + + require.Equal(t, "preset-fingerprint", cmd.RequestFingerprint) + require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale)) +} + +func TestQuantizeUsageBillingAmountPassesThroughNonFinite(t *testing.T) { + require.Equal(t, 0.0, QuantizeUsageBillingAmount(0)) + require.True(t, math.IsNaN(QuantizeUsageBillingAmount(math.NaN()))) + require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(1)), 1)) + require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(-1)), -1)) +} + +// 退款/负向金额同样按 half-away-from-zero 对称处理。 +func TestQuantizeUsageBillingAmountHandlesNegativeAmounts(t *testing.T) { + got := QuantizeUsageBillingAmount(-0.000078125) + want, _ := decimal.NewFromFloat(-0.000078125).Round(UsageBillingMonetaryScale).Float64() + + require.Equal(t, want, got) + require.Equal(t, -QuantizeUsageBillingAmount(0.000078125), got, + "正负金额必须对称量化") +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index b35d149ea8..7a41555ece 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -114,6 +114,12 @@ type UsageLog struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Nil means no mapping was applied (requested model was used as-is). UpstreamModel *string + // UpstreamResponseModel is the model declared by the successful upstream + // response before client-facing model rewrites or protocol conversion. + UpstreamResponseModel *string + // UpstreamModelMismatch is nil when no upstream model was observed. Otherwise + // it compares UpstreamResponseModel with the actual model sent upstream. + UpstreamModelMismatch *bool // ChannelID 渠道 ID ChannelID *int64 // ModelMappingChain 模型映射链,如 "a→b→c" diff --git a/backend/internal/service/usage_record_worker_pool.go b/backend/internal/service/usage_record_worker_pool.go index bb5ae452c8..babcc2599e 100644 --- a/backend/internal/service/usage_record_worker_pool.go +++ b/backend/internal/service/usage_record_worker_pool.go @@ -43,9 +43,18 @@ type UsageRecordSubmitMode string const ( UsageRecordSubmitModeEnqueued UsageRecordSubmitMode = "enqueued" UsageRecordSubmitModeDropped UsageRecordSubmitMode = "dropped" - UsageRecordSubmitModeSync UsageRecordSubmitMode = "sync_fallback" + // UsageRecordSubmitModeDroppedStopped 表示任务因池已停止(进程关停窗口)被丢弃。 + // 与显式 drop/sample 溢出策略的丢弃区分开:溢出丢弃是运维显式配置的取舍, + // 而关停窗口丢弃不是,计费关键任务应在调用侧降级为同步执行兜底。 + UsageRecordSubmitModeDroppedStopped UsageRecordSubmitMode = "dropped_stopped" + UsageRecordSubmitModeSync UsageRecordSubmitMode = "sync_fallback" ) +// Dropped 报告任务是否未被执行(入队失败且未同步执行)。 +func (m UsageRecordSubmitMode) Dropped() bool { + return m == UsageRecordSubmitModeDropped || m == UsageRecordSubmitModeDroppedStopped +} + // UsageRecordWorkerPoolOptions 使用量记录池配置。 type UsageRecordWorkerPoolOptions struct { WorkerCount int @@ -150,7 +159,7 @@ func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMo if p.pool == nil || p.pool.Stopped() { p.droppedPoolStopped.Add(1) p.logDrop("stopped") - return UsageRecordSubmitModeDropped + return UsageRecordSubmitModeDroppedStopped } _, ok := p.pool.TrySubmit(func() { @@ -163,7 +172,7 @@ func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMo if p.pool.Stopped() { p.droppedPoolStopped.Add(1) p.logDrop("stopped") - return UsageRecordSubmitModeDropped + return UsageRecordSubmitModeDroppedStopped } switch p.overflowPolicy { diff --git a/backend/internal/service/usage_record_worker_pool_test.go b/backend/internal/service/usage_record_worker_pool_test.go index f896e41d0a..d3e35cb879 100644 --- a/backend/internal/service/usage_record_worker_pool_test.go +++ b/backend/internal/service/usage_record_worker_pool_test.go @@ -176,7 +176,11 @@ func TestUsageRecordWorkerPool_SubmitAfterStop(t *testing.T) { pool.Stop() mode := pool.Submit(func(ctx context.Context) {}) - require.Equal(t, UsageRecordSubmitModeDropped, mode) + require.Equal(t, UsageRecordSubmitModeDroppedStopped, mode) + require.True(t, mode.Dropped()) + require.True(t, UsageRecordSubmitModeDropped.Dropped()) + require.False(t, UsageRecordSubmitModeEnqueued.Dropped()) + require.False(t, UsageRecordSubmitModeSync.Dropped()) require.GreaterOrEqual(t, pool.Stats().DroppedPoolStopped, uint64(1)) } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b590673126..2bfab8d586 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -181,6 +181,13 @@ type UserRepository interface { DisableTotp(ctx context.Context, userID int64) error } +// RegistrationEmailDomainRepository 是生产用户仓储为非白名单域名单账户兜底策略提供的可选能力。 +// 它独立于 UserRepository,避免无关测试桩和服务消费者实现注册专用方法。 +type RegistrationEmailDomainRepository interface { + CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) + CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *User, domain string) error +} + // RedeemUserAdjustmentRepository provides the atomic, floor-at-zero updates // used by negative-value redeem codes. It is intentionally narrower than // UserRepository because normal usage billing is allowed to overdraw. diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 8036888312..8e4e49dbb2 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -23,26 +23,27 @@ import ( // --- mock: UserRepository --- type mockUserRepo struct { - updateBalanceErr error - updateBalanceFn func(ctx context.Context, id int64, amount float64) error - deductBalanceFn func(ctx context.Context, id int64, amount float64) error - getByIDUser *User - getByIDErr error - identities []UserAuthIdentityRecord - unbindIdentityErr error - unboundProviders []string - updateLastActiveErr error - updateLastActiveUserIDs []int64 - updateLastActiveAt []time.Time - updateFn func(ctx context.Context, user *User) error - updateCalls int - updateFields []UserUpdateFields - upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) - upsertAvatarArgs []UpsertUserAvatarInput - deleteAvatarFn func(ctx context.Context, userID int64) error - deleteAvatarIDs []int64 - getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error) - txCalls int + updateBalanceErr error + updateBalanceFn func(ctx context.Context, id int64, amount float64) error + deductBalanceFn func(ctx context.Context, id int64, amount float64) error + deductAvailableBalanceFn func(ctx context.Context, id int64, amount float64) (float64, error) + getByIDUser *User + getByIDErr error + identities []UserAuthIdentityRecord + unbindIdentityErr error + unboundProviders []string + updateLastActiveErr error + updateLastActiveUserIDs []int64 + updateLastActiveAt []time.Time + updateFn func(ctx context.Context, user *User) error + updateCalls int + updateFields []UserUpdateFields + upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) + upsertAvatarArgs []UpsertUserAvatarInput + deleteAvatarFn func(ctx context.Context, userID int64) error + deleteAvatarIDs []int64 + getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error) + txCalls int } type mockUserRepoTxKey struct{} @@ -204,6 +205,13 @@ func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float return nil } +func (m *mockUserRepo) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error) { + if m.deductAvailableBalanceFn != nil { + return m.deductAvailableBalanceFn(ctx, id, amount) + } + return amount, nil +} + func (m *mockUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) { panic("unexpected AdjustBalance call") } diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 12e3dd428a..fb4d77f0f1 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -1,6 +1,10 @@ package service -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) const subscriptionDayDuration = 24 * time.Hour @@ -75,27 +79,92 @@ func (s *UserSubscription) NeedsDailyReset() bool { } func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool { - if s.DailyWindowStart == nil { - return false - } - if s.HasOneTimeDailyQuota() { - return false - } - return !now.Before(s.DailyWindowStart.Add(24 * time.Hour)) + _, ok := s.automaticDailyWindowStartAt(now) + return ok } func (s *UserSubscription) NeedsWeeklyReset() bool { + return s.NeedsWeeklyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool { if s.WeeklyWindowStart == nil { return false } - return time.Since(*s.WeeklyWindowStart) >= 7*24*time.Hour + return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour)) } func (s *UserSubscription) NeedsMonthlyReset() bool { + return s.NeedsMonthlyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool { if s.MonthlyWindowStart == nil { return false } - return time.Since(*s.MonthlyWindowStart) >= 30*24*time.Hour + return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour)) +} + +func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool { + _, ok := s.automaticDailyWindowStartAt(now) + return ok +} + +// automaticDailyWindowStartAt 计算日窗口按“配置时区日历日”对齐后的当前窗口起点。 +// 日额度固定在每天 0 点刷新(与周/月的期限对齐滚动窗口语义不同),因此只要持久化 +// 的窗口起点落在更早的日历日,就允许推进到今天 0 点。手动重置、激活等写入的任何 +// 非 0 点锚点都会在下一个 0 点被拉回日历日边界,不会永久漂移刷新时刻。 +func (s *UserSubscription) automaticDailyWindowStartAt(now time.Time) (time.Time, bool) { + if s.DailyWindowStart == nil { + return time.Time{}, false + } + if s.HasOneTimeDailyQuota() { + return time.Time{}, false + } + today := timezone.StartOfDay(now) + if !today.After(timezone.StartOfDay(*s.DailyWindowStart)) { + return time.Time{}, false + } + return today, true +} + +func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now) + return ok +} + +func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now) + return ok +} + +// automaticWindowStartAt 计算周/月窗口(期限对齐滚动窗口)的当前窗口起点。 +// 窗口从锚点按整数个 period 步进,且不越过订阅到期时间,避免最后一个不完整 +// 周期重复发放额度(issue #5051)。日窗口不走此函数,见 automaticDailyWindowStartAt。 +func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) { + if previous == nil { + return time.Time{}, false + } + + anchor := *previous + // Older subscriptions initialized their first windows at midnight on their + // start date. Only that initial value is unambiguous; later midnight anchors + // may be manual resets and must remain authoritative. + legacyAnchor := startOfDay(s.StartsAt) + if legacyAnchor.Before(s.StartsAt) && anchor.Equal(legacyAnchor) { + anchor = s.StartsAt + } + next := anchor.Add(period) + if now.Before(next) || !next.Before(s.ExpiresAt) { + return time.Time{}, false + } + + periods := now.Sub(anchor) / period + lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period + if periods > lastPeriodBeforeExpiry { + periods = lastPeriodBeforeExpiry + } + return anchor.Add(periods * period), true } func (s *UserSubscription) DailyResetTime() *time.Time { @@ -106,7 +175,8 @@ func (s *UserSubscription) DailyResetTime() *time.Time { t := s.ExpiresAt return &t } - t := s.DailyWindowStart.Add(24 * time.Hour) + // 日窗口按日历日对齐:下次刷新固定在窗口起点所在日的次日 0 点。 + t := timezone.StartOfDay(*s.DailyWindowStart).AddDate(0, 0, 1) return &t } diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index bc04812166..43e9799333 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) @@ -58,7 +59,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt") require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1))) require.NotNil(t, renewed.DailyWindowStart) - require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart) + require.Equal(t, timezone.StartOfDay(renewed.StartsAt), *renewed.DailyWindowStart, "续期后日窗口应锚定当天 0 点") require.Equal(t, 0.0, renewed.DailyUsageUSD) require.Equal(t, 0.0, renewed.WeeklyUsageUSD) require.Equal(t, 0.0, renewed.MonthlyUsageUSD) @@ -160,17 +161,18 @@ func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) { } func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) { - now := time.Now() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) startsAt := now.Add(-48 * time.Hour) dailyWindowStart := now.Add(-25 * time.Hour) repo := &dailyResetTrackingUserSubRepo{} svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } sub := &UserSubscription{ ID: 1, UserID: 10, GroupID: 20, StartsAt: startsAt, - ExpiresAt: startsAt.AddDate(0, 0, 2), + ExpiresAt: startsAt.AddDate(0, 0, 4), DailyUsageUSD: 10, DailyWindowStart: &dailyWindowStart, } diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index eeee0275f0..a20e4a4f26 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -10,6 +10,7 @@ import ( type UserSubscriptionRepository interface { Create(ctx context.Context, sub *UserSubscription) error GetByID(ctx context.Context, id int64) (*UserSubscription, error) + GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) GetByIDIncludeDeleted(ctx context.Context, id int64) (*UserSubscription, error) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error) @@ -28,8 +29,13 @@ type UserSubscriptionRepository interface { UpdateStatus(ctx context.Context, subscriptionID int64, status string) error UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error - ActivateWindows(ctx context.Context, id int64, start time.Time) error - ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error + // ActivateWindows 首次使用时激活用量窗口。日窗口按日历日对齐,锚点为当天 0 点 + // (dailyStart);周/月窗口为期限对齐滚动窗口,锚点为激活时刻(periodicStart)。 + // 仅当三个窗口均未激活时生效。 + ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error + // ResetUsageWindows 手动重置所选窗口的用量。日窗口锚点写入 dailyStart(当天 0 点, + // 保持 0 点刷新节奏不漂移);周/月窗口锚点写入 periodicStart(重置时刻)。 + ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error ResetWeeklyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error ResetMonthlyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error diff --git a/backend/internal/service/video_billing.go b/backend/internal/service/video_billing.go new file mode 100644 index 0000000000..66a5b950f6 --- /dev/null +++ b/backend/internal/service/video_billing.go @@ -0,0 +1,155 @@ +package service + +import ( + "log/slog" + "sort" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" +) + +// Canonical video price family keys used in groups.video_model_prices JSONB. +const ( + VideoPriceFamilyGrokImagineVideo = "grok-imagine-video" + VideoPriceFamilyGrokImagineVideo15 = "grok-imagine-video-1.5" +) + +// CanonicalGrokImagineVideoPriceFamily normalizes model aliases / preview / legacy +// IDs onto the price-family keys stored in video_model_prices. +func CanonicalGrokImagineVideoPriceFamily(model string) string { + if model == "" { + return "" + } + // Prefer shared xAI helper for known aliases. Keep future native Imagine + // models distinct so operators can assign them independent prices. + if c := xai.CanonicalImagineVideoModel(model); c != "" { + switch c { + case xai.DefaultImagineVideo15Model: + return VideoPriceFamilyGrokImagineVideo15 + case xai.DefaultImagineVideoModel: + return VideoPriceFamilyGrokImagineVideo + } + if strings.HasPrefix(c, "grok-imagine-video-") { + return c + } + } + m := strings.ToLower(strings.TrimSpace(model)) + for _, prefix := range []string{"xai/", "x-ai/", "grok/"} { + if strings.HasPrefix(m, prefix) { + m = strings.TrimPrefix(m, prefix) + break + } + } + switch { + case m == "grok-imagine-video-1.5" || m == "grok-imagine-video-1.5-preview" || + m == "grok-video-1.5" || strings.Contains(m, "video-1.5"): + return VideoPriceFamilyGrokImagineVideo15 + case m == "grok-imagine-video" || m == "grok-imagine-video-preview" || + m == "grok-video" || m == "grok-video-latest": + return VideoPriceFamilyGrokImagineVideo + default: + return "" + } +} + +// NormalizeVideoModelPrices cleans and canonicalizes a per-model resolution map. +// Keys become price families; tiers use 480p/720p/1080p. Negative prices dropped. +// +// Model keys are walked in sorted order rather than in Go map order: several +// aliases can canonicalize onto the same family, and an unordered walk would +// make the winning price for a conflicting tier vary between processes. +// Unrecognized tiers are dropped with a warning instead of silently collapsing +// into the 480p bucket. +func NormalizeVideoModelPrices(in map[string]map[string]float64) map[string]map[string]float64 { + if len(in) == 0 { + return nil + } + modelKeys := make([]string, 0, len(in)) + for modelKey := range in { + modelKeys = append(modelKeys, modelKey) + } + sort.Strings(modelKeys) + out := make(map[string]map[string]float64) + for _, modelKey := range modelKeys { + tierPrices := in[modelKey] + if len(tierPrices) == 0 { + continue + } + family := CanonicalGrokImagineVideoPriceFamily(modelKey) + if family == "" { + key := strings.ToLower(strings.TrimSpace(modelKey)) + switch key { + case VideoPriceFamilyGrokImagineVideo, VideoPriceFamilyGrokImagineVideo15: + family = key + default: + if key == "" { + continue + } + family = key + } + } + normalizedTiers := out[family] + if normalizedTiers == nil { + normalizedTiers = make(map[string]float64) + } + tierKeys := make([]string, 0, len(tierPrices)) + for tierKey := range tierPrices { + tierKeys = append(tierKeys, tierKey) + } + sort.Strings(tierKeys) + for _, tierKey := range tierKeys { + price := tierPrices[tierKey] + if price < 0 { + continue + } + tier, ok := LookupVideoBillingResolution(tierKey) + if !ok { + slog.Warn("video_model_prices_unknown_resolution_dropped", + "model_key", modelKey, + "family", family, + "resolution", tierKey) + continue + } + if existing, exists := normalizedTiers[tier]; exists && existing != price { + slog.Warn("video_model_prices_conflicting_tier_price", + "model_key", modelKey, + "family", family, + "resolution", tier, + "previous_price", existing, + "price", price) + } + normalizedTiers[tier] = price + } + if len(normalizedTiers) > 0 { + out[family] = normalizedTiers + } + } + if len(out) == 0 { + return nil + } + return out +} + +// LookupVideoModelPrice returns a per-second price from a model×resolution map, or nil. +func LookupVideoModelPrice(prices map[string]map[string]float64, model, resolution string) *float64 { + if len(prices) == 0 { + return nil + } + family := CanonicalGrokImagineVideoPriceFamily(model) + if family == "" { + family = strings.ToLower(strings.TrimSpace(model)) + } + if family == "" { + return nil + } + tierPrices, ok := prices[family] + if !ok || len(tierPrices) == 0 { + return nil + } + tier := NormalizeVideoBillingResolutionOrDefault(resolution) + if price, ok := tierPrices[tier]; ok { + p := price + return &p + } + return nil +} diff --git a/backend/internal/service/video_billing_resolution.go b/backend/internal/service/video_billing_resolution.go index cb713f6877..11782db851 100644 --- a/backend/internal/service/video_billing_resolution.go +++ b/backend/internal/service/video_billing_resolution.go @@ -31,15 +31,27 @@ func NormalizeVideoBillingDurationSecondsOrDefault(durationSeconds int) int { return durationSeconds } -func NormalizeVideoBillingResolutionOrDefault(resolution string) string { +// LookupVideoBillingResolution 归一化分辨率并报告是否为已知档位。 +// 配置解析路径必须用它而不是 OrDefault:把无法识别的档位(如 "4k"、拼错的 +// "1080i")静默折算成 480p,会让管理员配的高分辨率单价被挂到低分辨率档上。 +func LookupVideoBillingResolution(resolution string) (string, bool) { switch strings.ToLower(strings.TrimSpace(resolution)) { case "480", "480p", "sd": - return VideoBillingResolution480P + return VideoBillingResolution480P, true case "720", "720p", "hd": - return VideoBillingResolution720P + return VideoBillingResolution720P, true case "1080", "1080p", "full_hd", "full-hd", "fhd": - return VideoBillingResolution1080P + return VideoBillingResolution1080P, true default: - return VideoBillingResolution480P + return "", false } } + +// NormalizeVideoBillingResolutionOrDefault 用于运行时计费:上游回传的分辨率 +// 缺失或无法识别时按最低档兜底,保证请求仍可计费。 +func NormalizeVideoBillingResolutionOrDefault(resolution string) string { + if normalized, ok := LookupVideoBillingResolution(resolution); ok { + return normalized + } + return VideoBillingResolution480P +} diff --git a/backend/internal/service/video_billing_test.go b/backend/internal/service/video_billing_test.go new file mode 100644 index 0000000000..6cf2e936c8 --- /dev/null +++ b/backend/internal/service/video_billing_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCanonicalGrokImagineVideoPriceFamily(t *testing.T) { + t.Parallel() + require.Equal(t, VideoPriceFamilyGrokImagineVideo, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video")) + require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5")) + require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-1.5-preview")) + require.Equal(t, VideoPriceFamilyGrokImagineVideo15, CanonicalGrokImagineVideoPriceFamily("xai/grok-video-1.5")) + require.Equal(t, "grok-imagine-video-2", CanonicalGrokImagineVideoPriceFamily("grok-imagine-video-2")) + require.Equal(t, "grok-imagine-video-2", CanonicalGrokImagineVideoPriceFamily("xai/grok-imagine-video-2")) +} + +func TestNormalizeAndLookupVideoModelPrices(t *testing.T) { + t.Parallel() + raw := map[string]map[string]float64{ + "grok-imagine-video-1.5-preview": {"480p": 0.08, "720p": 0.14}, + "grok-imagine-video": {"480p": 0.05}, + "grok-imagine-video-2": {"1080p": 0.4}, + } + norm := NormalizeVideoModelPrices(raw) + require.NotNil(t, norm) + require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo15) + require.Contains(t, norm, VideoPriceFamilyGrokImagineVideo) + require.Contains(t, norm, "grok-imagine-video-2") + + p15 := LookupVideoModelPrice(norm, "grok-imagine-video-1.5", "480p") + require.NotNil(t, p15) + require.InDelta(t, 0.08, *p15, 1e-9) + + pBase := LookupVideoModelPrice(norm, "grok-imagine-video", "480p") + require.NotNil(t, pBase) + require.InDelta(t, 0.05, *pBase, 1e-9) + // A missing model-specific tier must fall back to the flat tier price, + // rather than borrowing another model-specific resolution. + require.Nil(t, LookupVideoModelPrice(norm, "grok-imagine-video", "720p")) + + p2 := LookupVideoModelPrice(norm, "grok-imagine-video-2", "1080p") + require.NotNil(t, p2) + require.InDelta(t, 0.4, *p2, 1e-9) + + // Unmatched model → nil (caller falls back to flat columns / defaults). + require.Nil(t, LookupVideoModelPrice(norm, "unknown-model", "480p")) +} + +func TestNormalizeVideoModelPricesDropsUnknownResolutions(t *testing.T) { + t.Parallel() + // "4k" and "1080i" are not billable tiers. Collapsing them into 480p would + // charge a 480p request at the operator's high-resolution price. + norm := NormalizeVideoModelPrices(map[string]map[string]float64{ + "grok-imagine-video": {"480p": 0.05, "4k": 0.50, "1080i": 0.30}, + }) + require.NotNil(t, norm) + require.Equal(t, map[string]float64{VideoBillingResolution480P: 0.05}, norm[VideoPriceFamilyGrokImagineVideo]) + + // A model whose tiers are all unrecognized contributes no family at all. + require.Nil(t, NormalizeVideoModelPrices(map[string]map[string]float64{ + "grok-imagine-video": {"4k": 0.50}, + })) +} + +func TestNormalizeVideoModelPricesIsDeterministicAcrossAliasConflicts(t *testing.T) { + t.Parallel() + // Both keys canonicalize onto grok-imagine-video-1.5 and disagree on 480p. + // Whichever price wins, it must be the same one on every run — a Go map walk + // would let two processes bill the same request differently. + raw := map[string]map[string]float64{ + "grok-imagine-video-1.5": {"480p": 0.08}, + "grok-imagine-video-1.5-preview": {"480p": 0.11}, + "grok-video-1.5": {"480p": 0.09}, + } + first := NormalizeVideoModelPrices(raw) + require.NotNil(t, first) + for i := 0; i < 50; i++ { + require.Equal(t, first, NormalizeVideoModelPrices(raw), "run %d diverged", i) + } + + // Aliases within one model key normalize to the same tier deterministically too. + aliased := map[string]map[string]float64{ + "grok-imagine-video": {"720": 0.12, "720p": 0.13, "hd": 0.14}, + } + firstAliased := NormalizeVideoModelPrices(aliased) + require.NotNil(t, firstAliased) + for i := 0; i < 50; i++ { + require.Equal(t, firstAliased, NormalizeVideoModelPrices(aliased), "run %d diverged", i) + } +} + +func TestLookupVideoBillingResolutionReportsUnknownTiers(t *testing.T) { + t.Parallel() + for _, in := range []string{"480", "480p", "SD", "720", "hd", "1080", "full-hd", " fhd "} { + normalized, ok := LookupVideoBillingResolution(in) + require.True(t, ok, "input=%q", in) + require.NotEmpty(t, normalized) + } + for _, in := range []string{"", "4k", "1080i", "2160p", "potato"} { + normalized, ok := LookupVideoBillingResolution(in) + require.False(t, ok, "input=%q", in) + require.Empty(t, normalized) + } + // Runtime billing still needs a tier for unrecognized upstream values. + require.Equal(t, VideoBillingResolution480P, NormalizeVideoBillingResolutionOrDefault("4k")) + require.Equal(t, VideoBillingResolution1080P, NormalizeVideoBillingResolutionOrDefault("full_hd")) +} + +func TestVideoModelPriceMissingTierFallsBackToFlatTierPrice(t *testing.T) { + t.Parallel() + flat720P := 0.7 + service := &BillingService{} + + result := service.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, &VideoPriceConfig{ + Price720P: &flat720P, + ModelPrices: map[string]map[string]float64{ + VideoPriceFamilyGrokImagineVideo: {VideoBillingResolution480P: 0.05}, + }, + }, 1) + + require.InDelta(t, flat720P, result.TotalCost, 1e-9) +} diff --git a/backend/internal/service/websearch_config.go b/backend/internal/service/websearch_config.go index bb33368d39..d4b0823adc 100644 --- a/backend/internal/service/websearch_config.go +++ b/backend/internal/service/websearch_config.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "sync/atomic" @@ -106,6 +107,15 @@ func (s *SettingService) loadWebSearchConfigFromDB() (*WebSearchEmulationConfig, raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyWebSearchEmulationConfig) if err != nil { + // Missing key is the normal first-boot state: return empty disabled config. + if errors.Is(err, ErrSettingNotFound) { + cfg := &WebSearchEmulationConfig{} + webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{ + config: cfg, + expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(), + }) + return cfg, nil + } webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{ config: &WebSearchEmulationConfig{}, expiresAt: time.Now().Add(webSearchEmulationErrorTTL).UnixNano(), diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2a07c73ea5..93555730a7 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -3,6 +3,7 @@ package service import ( "context" "database/sql" + "os" "time" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -10,11 +11,21 @@ import ( "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/google/wire" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) +func ProvideGrokOAuthService(proxyRepo ProxyRepository, oauthClient GrokOAuthClient, cfg *config.Config, redisClient *redis.Client) *GrokOAuthService { + svc := NewGrokOAuthService(proxyRepo, oauthClient, cfg) + // wire.go is depguard-exempt for redis; construct the Redis session store here. + if redisClient != nil { + svc = svc.WithSessionStore(xai.NewRedisSessionStore(redisClient)) + } + return svc +} + // BuildInfo contains build information type BuildInfo struct { Version string @@ -41,6 +52,45 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService { return NewEmailQueueService(emailService, 3) } +// ProvideAuthService wires the optional captcha providers into AuthService while +// keeping NewAuthService's public constructor compatible with existing tests. +func ProvideAuthService( + entClient *dbent.Client, + userRepo UserRepository, + redeemRepo RedeemCodeRepository, + refreshTokenCache RefreshTokenCache, + cfg *config.Config, + settingService *SettingService, + emailService *EmailService, + turnstileService *TurnstileService, + tencentCaptchaService *TencentCaptchaService, + aliyunCaptchaService *AliyunCaptchaService, + emailQueueService *EmailQueueService, + promoService *PromoService, + defaultSubAssigner DefaultSubscriptionAssigner, + affiliateService *AffiliateService, + userPlatformQuotaRepo UserPlatformQuotaRepository, +) *AuthService { + svc := NewAuthService( + entClient, + userRepo, + redeemRepo, + refreshTokenCache, + cfg, + settingService, + emailService, + turnstileService, + emailQueueService, + promoService, + defaultSubAssigner, + affiliateService, + userPlatformQuotaRepo, + ) + svc.SetTencentCaptchaService(tencentCaptchaService) + svc.SetAliyunCaptchaService(aliyunCaptchaService) + return svc +} + // ProvideOAuthRefreshAPI creates OAuthRefreshAPI with the default lock TTL. func ProvideOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache) *OAuthRefreshAPI { return NewOAuthRefreshAPI(accountRepo, tokenCache) @@ -180,6 +230,7 @@ func ProvideAccountTestService( cfg *config.Config, tlsFPProfileService *TLSFingerprintProfileService, openAIGatewayService *OpenAIGatewayService, + settingService *SettingService, ) *AccountTestService { service := NewAccountTestService( accountRepo, @@ -192,6 +243,7 @@ func ProvideAccountTestService( tlsFPProfileService, ) service.agentIdentityWS = openAIGatewayService + service.SetSettingService(settingService) return service } @@ -202,8 +254,11 @@ func ProvideGrokQuotaService( httpUpstream HTTPUpstream, cfg *config.Config, usageLogRepo UsageLogRepository, + settingService *SettingService, ) *GrokQuotaService { - return NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, cfg, usageLogRepo) + service := NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, cfg, usageLogRepo) + service.SetSettingService(settingService) + return service } // ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection @@ -274,6 +329,18 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe return svc } +// ProvideOpenAICodexVersionSyncService creates and starts OpenAICodexVersionSyncService. +// 出站 Codex 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。 +func ProvideOpenAICodexVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, +) *OpenAICodexVersionSyncService { + svc := NewOpenAICodexVersionSyncService(settingRepo, settingService, githubClient, openAICodexVersionSyncInterval) + svc.Start() + return svc +} + // ProvideProxyExpiryService creates and starts ProxyExpiryService. func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService { svc := NewProxyExpiryService(proxyRepo, time.Minute) @@ -637,6 +704,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err) } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) + // enforceCodexIdentityHeaders 是所有 Codex 出站路径共用的纯函数收口点,拿不到 ctx, + // 故注入无参解析器;解析器内部自带 60s TTL 缓存,热路径不触库。 + SetCodexCanonicalUserAgentResolver(func() string { + return svc.GetOpenAICodexCanonicalUserAgent(context.Background()) + }) return svc } @@ -675,7 +747,7 @@ func ProvideAPIKeyService( // ProviderSet is the Wire provider set for all services var ProviderSet = wire.NewSet( // Core services - NewAuthService, + ProvideAuthService, NewPasskeyService, NewUserService, ProvideAPIKeyService, @@ -706,7 +778,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(AccountRuntimeBlocker), new(*OpenAIGatewayService)), NewOAuthService, ProvideOpenAIOAuthService, - NewGrokOAuthService, + ProvideGrokOAuthService, wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)), NewGeminiOAuthService, NewGeminiQuotaService, @@ -744,6 +816,8 @@ var ProviderSet = wire.NewSet( NewNotificationEmailService, ProvideEmailQueueService, NewTurnstileService, + NewTencentCaptchaService, + NewAliyunCaptchaService, NewSubscriptionService, wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)), ProvideConcurrencyService, @@ -756,6 +830,7 @@ var ProviderSet = wire.NewSet( ProvideTokenRefreshService, wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)), ProvideAccountExpiryService, + ProvideOpenAICodexVersionSyncService, ProvideProxyExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, @@ -786,6 +861,8 @@ var ProviderSet = wire.NewSet( ProvideBalanceNotifyService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, + ProvideChannelMonitorV2Service, + ProvideChannelMonitorV2Aggregator, NewChannelMonitorRequestTemplateService, ProvideUserPlatformQuotaUsageFlusher, ) @@ -827,11 +904,15 @@ func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService, lockCache Lead // ProvideChannelMonitorService 创建渠道监控服务(CRUD + RunCheck + 用户视图聚合)。 // 加密器复用 wire 中已注入的 SecretEncryptor(AES-256-GCM)。 +// settingService gates RunCheck via channel_monitor_enabled + channel_monitor_mode. func ProvideChannelMonitorService( repo ChannelMonitorRepository, encryptor SecretEncryptor, + settingService *SettingService, ) *ChannelMonitorService { - return NewChannelMonitorService(repo, encryptor) + svc := NewChannelMonitorService(repo, encryptor) + svc.SetRuntimeReader(settingService) + return svc } // ProvideChannelMonitorRunner 创建并启动渠道监控调度器。 @@ -840,7 +921,32 @@ func ProvideChannelMonitorService( // settingService 用于 runner 每次 fire 读取功能开关。 func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { r := NewChannelMonitorRunner(svc, settingService) - svc.SetScheduler(r) + if svc != nil { + // Ensure runtime reader is set even if ProvideChannelMonitorService + // was constructed without settings (tests / alternate providers). + svc.SetRuntimeReader(settingService) + svc.SetScheduler(r) + } r.Start() return r } + +// ProvideChannelMonitorV2Service wires settings for user-facing privacy flags +// (e.g. hide RPM/TPM throughput). +func ProvideChannelMonitorV2Service(repo ChannelMonitorV2Repository, settingService *SettingService) *ChannelMonitorV2Service { + svc := NewChannelMonitorV2Service(repo) + svc.SetRuntimeReader(settingService) + return svc +} + +// ProvideChannelMonitorV2Aggregator starts the passive minute-rollup worker. +// Aggregation only runs when channel_monitor_enabled=true and mode=v2 (and V2 config enabled). +// Set CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR=1 to skip Start (local demo with seeded facts). +func ProvideChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settingService *SettingService) *ChannelMonitorV2Aggregator { + aggregator := NewChannelMonitorV2Aggregator(repo, db, settingService) + if os.Getenv("CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR") == "1" { + return aggregator + } + aggregator.Start() + return aggregator +} diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index a0bd4bc669..9890ec14a5 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -104,6 +104,20 @@ func (c StubGatewayCache) DeleteSessionAccountID(_ context.Context, _ int64, _ s return nil } +func (c StubGatewayCache) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error { + return nil +} +func (c StubGatewayCache) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) { + return nil, nil +} +func (c StubGatewayCache) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) { + return true, nil +} + +func (c StubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) error { + return nil +} + // ============================================================ // StubSessionLimitCache — service.SessionLimitCache 的空实现 // ============================================================ diff --git a/backend/migrations/192_group_profit_control.sql b/backend/migrations/192_group_profit_control.sql new file mode 100644 index 0000000000..072b3c5db1 --- /dev/null +++ b/backend/migrations/192_group_profit_control.sql @@ -0,0 +1,9 @@ +-- Per-group profit control for scheduling admission. +-- Admission rule at request time: an account qualifies iff its cost multiplier +-- U (accounts.rate_multiplier) satisfies U <= D * (1 - margin - buffer), where +-- D is the requester's effective downstream multiplier at the request's +-- pricing instant. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS profit_control_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS profit_min_margin DECIMAL(10,4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS profit_safety_buffer DECIMAL(10,4) NOT NULL DEFAULT 0; diff --git a/backend/migrations/193_group_profit_control_auth_cache_invalidation.sql b/backend/migrations/193_group_profit_control_auth_cache_invalidation.sql new file mode 100644 index 0000000000..f32f6e6f8b --- /dev/null +++ b/backend/migrations/193_group_profit_control_auth_cache_invalidation.sql @@ -0,0 +1,47 @@ +-- Profit-control fields are part of the API-key auth snapshot and gate the +-- scheduling admission filter; the profit threshold D additionally depends on +-- group pricing and peak-window fields. Extend the durable invalidation +-- trigger so out-of-band group edits (direct SQL, crash between update and +-- app-level invalidation) cannot leave cached snapshots using stale +-- profit-control inputs. Normal admin saves already invalidate via +-- InvalidateAuthCacheByGroupID; this trigger is the durable backstop. Based on +-- the latest function body from 186_group_auth_cache_image_generation.sql. + +CREATE OR REPLACE FUNCTION enqueue_group_auth_cache_invalidation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + target_group_id BIGINT; +BEGIN + target_group_id := OLD.id; + IF TG_OP = 'UPDATE' + AND OLD.status IS NOT DISTINCT FROM NEW.status + AND OLD.is_exclusive IS NOT DISTINCT FROM NEW.is_exclusive + AND OLD.allow_image_generation IS NOT DISTINCT FROM NEW.allow_image_generation + AND OLD.platform IS NOT DISTINCT FROM NEW.platform + AND OLD.subscription_type IS NOT DISTINCT FROM NEW.subscription_type + AND OLD.rate_multiplier IS NOT DISTINCT FROM NEW.rate_multiplier + AND OLD.peak_rate_enabled IS NOT DISTINCT FROM NEW.peak_rate_enabled + AND OLD.peak_start IS NOT DISTINCT FROM NEW.peak_start + AND OLD.peak_end IS NOT DISTINCT FROM NEW.peak_end + AND OLD.peak_rate_multiplier IS NOT DISTINCT FROM NEW.peak_rate_multiplier + AND OLD.profit_control_enabled IS NOT DISTINCT FROM NEW.profit_control_enabled + AND OLD.profit_min_margin IS NOT DISTINCT FROM NEW.profit_min_margin + AND OLD.profit_safety_buffer IS NOT DISTINCT FROM NEW.profit_safety_buffer + AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN + RETURN NEW; + END IF; + + INSERT INTO auth_cache_invalidation_outbox (cache_key) + SELECT encode(sha256(convert_to(k.key, 'UTF8')), 'hex') + FROM api_keys AS k + WHERE k.group_id = target_group_id + AND k.deleted_at IS NULL + AND k.key <> ''; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; diff --git a/backend/migrations/194_add_usage_log_upstream_response_model.sql b/backend/migrations/194_add_usage_log_upstream_response_model.sql new file mode 100644 index 0000000000..a5865aca17 --- /dev/null +++ b/backend/migrations/194_add_usage_log_upstream_response_model.sql @@ -0,0 +1,3 @@ +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS upstream_response_model VARCHAR(200), + ADD COLUMN IF NOT EXISTS upstream_model_mismatch BOOLEAN; diff --git a/backend/migrations/194_channel_monitor_v2.sql b/backend/migrations/194_channel_monitor_v2.sql new file mode 100644 index 0000000000..4e18ab152c --- /dev/null +++ b/backend/migrations/194_channel_monitor_v2.sql @@ -0,0 +1,119 @@ +-- Passive channel monitor V2. This migration creates empty rollup tables only; +-- backfill is intentionally handled by a bounded background worker. + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_config ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + version INTEGER NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + refresh_interval_seconds INTEGER NOT NULL DEFAULT 60 + CHECK (refresh_interval_seconds IN (60, 300)), + platforms JSONB NOT NULL DEFAULT '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb, + group_ids BIGINT[] NOT NULL DEFAULT '{}', + updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO channel_monitor_v2_config (id) VALUES (1) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + upstream_affected_requests BIGINT NOT NULL DEFAULT 0, + upstream_attempt_count BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_start, platform, group_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_platform_time + ON channel_monitor_v2_metrics_1m (platform, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_group_time + ON channel_monitor_v2_metrics_1m (group_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_model_time + ON channel_monitor_v2_metrics_1m (model, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_start, platform, group_id, model, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_user_time + ON channel_monitor_v2_user_metrics_1m (user_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_time + ON channel_monitor_v2_user_metrics_1m (bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + error_category TEXT NOT NULL, + taxonomy_version SMALLINT NOT NULL, + error_requests BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start, platform, group_id, model, error_category, taxonomy_version) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_time + ON channel_monitor_v2_error_metrics_1m (bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_category_time + ON channel_monitor_v2_error_metrics_1m (error_category, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_1m ( + bucket_start TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')), + upper_bound_ms INTEGER NOT NULL, + sample_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_time + ON channel_monitor_v2_latency_histograms_1m (bucket_start DESC, metric); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_watermarks ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + usage_coverage_start TIMESTAMPTZ, + error_coverage_start TIMESTAMPTZ, + data_through TIMESTAMPTZ, + last_successful_at TIMESTAMPTZ, + backfill_cursor TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO channel_monitor_v2_watermarks (id) VALUES (1) +ON CONFLICT (id) DO NOTHING; + +COMMENT ON TABLE channel_monitor_v2_metrics_1m IS + 'One-minute passive channel health facts derived from real user requests; never from active probes.'; +COMMENT ON COLUMN channel_monitor_v2_error_metrics_1m.taxonomy_version IS + 'Version of the ordered error classification rules used for this aggregate.'; diff --git a/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql new file mode 100644 index 0000000000..811ca8786c --- /dev/null +++ b/backend/migrations/195_add_usage_log_upstream_model_mismatch_index_notx.sql @@ -0,0 +1,3 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at + ON usage_logs (created_at DESC, id DESC) + WHERE upstream_model_mismatch IS TRUE; diff --git a/backend/migrations/195_channel_monitor_mode.sql b/backend/migrations/195_channel_monitor_mode.sql new file mode 100644 index 0000000000..ba3d2f95da --- /dev/null +++ b/backend/migrations/195_channel_monitor_mode.sql @@ -0,0 +1,5 @@ +-- Channel monitor exclusive mode: v1 (active probes) or v2 (passive aggregation). +-- Default v1 preserves existing active-probe behavior; operators opt in to v2. +INSERT INTO settings (key, value) +VALUES ('channel_monitor_mode', 'v1') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql b/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql new file mode 100644 index 0000000000..c3e65f26a4 --- /dev/null +++ b/backend/migrations/196_channel_monitor_v2_ignored_error_categories.sql @@ -0,0 +1,3 @@ +-- Categories excluded from error_rate / health scoring (still shown in error breakdown). +ALTER TABLE channel_monitor_v2_config + ADD COLUMN IF NOT EXISTS ignored_error_categories TEXT[] NOT NULL DEFAULT '{}'; diff --git a/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql b/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql new file mode 100644 index 0000000000..248f2afbf3 --- /dev/null +++ b/backend/migrations/197_channel_monitor_v2_seed_popular_models.sql @@ -0,0 +1,139 @@ +-- Seed factory channel_monitor_v2_config.platforms with a popular-model allow-list +-- (high-traffic names from production usage). Presentation semantics: +-- - listed models → own dimension +-- - unlisted models → __other__ +-- - empty models list (operator cleared) → show every real model name (no collapse) +-- Aggregation always stores the real model name; this only affects presentation. +-- +-- Only rewrite rows whose platforms still look like the empty-models factory +-- default (every platform has models: []). Custom operator configs are left alone. + +UPDATE channel_monitor_v2_config +SET + platforms = $platforms$ +[ + { + "platform": "anthropic", + "enabled": true, + "models": [ + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4.6", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-opus-4", + "claude-fable-5", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-sonnet-4.6", + "claude-sonnet-4-5", + "claude-sonnet-4.5", + "claude-sonnet-4", + "claude-haiku-4-5", + "claude-haiku-4.5", + "claude-3-7-sonnet", + "claude-3-5-sonnet", + "claude-3-5-haiku" + ] + }, + { + "platform": "openai", + "enabled": true, + "models": [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.6", + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", + "gpt-5.2", + "gpt-5.2-pro", + "gpt-5", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-4", + "o3", + "o3-mini", + "o4-mini", + "codex-auto-review", + "gpt-image-2", + "gpt-image-1" + ] + }, + { + "platform": "grok", + "enabled": true, + "models": [ + "grok-4.5", + "grok-4.5-latest", + "grok-4.3", + "grok-4.20-multi-agent", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", + "grok-build", + "grok-3-mini", + "grok-3-mini-fast", + "grok-code-fast-1", + "grok-code-fast" + ] + }, + { + "platform": "gemini", + "enabled": true, + "models": [ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-flash-image", + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-3-flash", + "gemini-3-pro-image", + "gemini-3.1-pro-high", + "gemini-3.1-pro-low", + "gemini-3.1-flash-lite", + "gemini-3.5-flash", + "gemini-3.5-flash-lite" + ] + }, + { + "platform": "kiro", + "enabled": true, + "models": [ + "claude-opus-4-8", + "claude-opus-4-6", + "claude-opus-4-5", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "claude-haiku-4-5" + ] + }, + { + "platform": "antigravity", + "enabled": true, + "models": [ + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "gemini-2.5-pro", + "gemini-2.5-flash" + ] + } +] +$platforms$::jsonb, + version = version + 1, + updated_at = NOW() +WHERE id = 1 + -- Only seed the exact factory default from migration 194. A row with custom + -- enabled=false, platform set/order, or any other operator tweak must be left + -- untouched even if every models list is still empty. + AND platforms = '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb; diff --git a/backend/migrations/198_channel_monitor_v2_health_thresholds.sql b/backend/migrations/198_channel_monitor_v2_health_thresholds.sql new file mode 100644 index 0000000000..dad533104a --- /dev/null +++ b/backend/migrations/198_channel_monitor_v2_health_thresholds.sql @@ -0,0 +1,33 @@ +-- Configurable Channel Monitor V2 health scoring thresholds. +-- Defaults are intentionally tolerant: small amounts of real traffic errors or +-- zero cache rate should not immediately paint the user page as "abnormal". +ALTER TABLE channel_monitor_v2_config + ADD COLUMN IF NOT EXISTS health_thresholds JSONB NOT NULL DEFAULT '{ + "minimum_sample": 50, + "warning_error_rate": 0.05, + "critical_error_rate": 0.20, + "target_ttft_ms": 3000, + "warning_ttft_ms": 8000, + "critical_ttft_ms": 20000, + "warning_cache_rate": 0, + "critical_cache_rate": 0, + "error_weight": 0.60, + "ttft_weight": 0.20, + "cache_weight": 0.20 + }'::jsonb; + +UPDATE channel_monitor_v2_config +SET health_thresholds = '{ + "minimum_sample": 50, + "warning_error_rate": 0.05, + "critical_error_rate": 0.20, + "target_ttft_ms": 3000, + "warning_ttft_ms": 8000, + "critical_ttft_ms": 20000, + "warning_cache_rate": 0, + "critical_cache_rate": 0, + "error_weight": 0.60, + "ttft_weight": 0.20, + "cache_weight": 0.20 + }'::jsonb || health_thresholds +WHERE id = 1; diff --git a/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql b/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql new file mode 100644 index 0000000000..f033401e06 --- /dev/null +++ b/backend/migrations/199_channel_monitor_v2_fixed_rollups.sql @@ -0,0 +1,95 @@ +-- Fixed-size Channel Monitor V2 rollups for the 24h / 7d / 30d views. +-- Source of truth remains the 1-minute passive rollup; these tables cache the +-- UI bucket sizes so historical buckets do not need to be re-aggregated on each +-- filter change. The background aggregator overwrites affected buckets. + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + upstream_affected_requests BIGINT NOT NULL DEFAULT 0, + upstream_attempt_count BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_platform_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, platform, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_group_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, group_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_model_time + ON channel_monitor_v2_metrics_rollup (bucket_seconds, model, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL, + success_requests BIGINT NOT NULL DEFAULT 0, + error_requests BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_tokens BIGINT NOT NULL DEFAULT 0, + ttft_sum_ms BIGINT NOT NULL DEFAULT 0, + ttft_count BIGINT NOT NULL DEFAULT 0, + duration_sum_ms BIGINT NOT NULL DEFAULT 0, + duration_count BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_user_time + ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, user_id, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_time + ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + error_category TEXT NOT NULL, + taxonomy_version SMALLINT NOT NULL, + error_requests BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, error_category, taxonomy_version) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_time + ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, bucket_start DESC); +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_category_time + ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, error_category, bucket_start DESC); + +CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_rollup ( + bucket_start TIMESTAMPTZ NOT NULL, + bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)), + platform TEXT NOT NULL, + group_id BIGINT NOT NULL DEFAULT 0, + model TEXT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')), + upper_bound_ms INTEGER NOT NULL, + sample_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms) +); + +CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_rollup_time + ON channel_monitor_v2_latency_histograms_rollup (bucket_seconds, bucket_start DESC, metric); + +COMMENT ON TABLE channel_monitor_v2_metrics_rollup IS + 'Precomputed fixed UI buckets derived from channel_monitor_v2_metrics_1m for 24h/7d/30d views.'; diff --git a/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql b/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql new file mode 100644 index 0000000000..26cc54f09b --- /dev/null +++ b/backend/migrations/200_channel_monitor_v2_rollup_permissions.sql @@ -0,0 +1,9 @@ +-- Runtime aggregator reads/writes the fixed rollup caches through the role that +-- executes migrations. Use CURRENT_USER so custom DATABASE_USER values work; +-- PostgreSQL permits this form in GRANT and it remains idempotent. +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + channel_monitor_v2_metrics_rollup, + channel_monitor_v2_user_metrics_rollup, + channel_monitor_v2_error_metrics_rollup, + channel_monitor_v2_latency_histograms_rollup +TO CURRENT_USER; diff --git a/backend/migrations/201_channel_monitor_v2_refresh_5m.sql b/backend/migrations/201_channel_monitor_v2_refresh_5m.sql new file mode 100644 index 0000000000..7bd67e4a94 --- /dev/null +++ b/backend/migrations/201_channel_monitor_v2_refresh_5m.sql @@ -0,0 +1,12 @@ +-- Channel Monitor V2 is designed around fixed UI buckets; refresh passive +-- aggregation every 5 minutes and recompute the trailing bucket for each range. +-- Only migrate the untouched factory row. A row with a newer version or an +-- updater identity reflects an operator choice and must retain its 60-second +-- interval (or any other valid setting). +UPDATE channel_monitor_v2_config +SET refresh_interval_seconds = 300, + updated_at = NOW() +WHERE id = 1 + AND version = 1 + AND updated_by IS NULL + AND refresh_interval_seconds = 60; diff --git a/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql b/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql new file mode 100644 index 0000000000..a19b8722df --- /dev/null +++ b/backend/migrations/202_channel_monitor_v2_full_table_permissions.sql @@ -0,0 +1,15 @@ +-- Extend privileges for all Channel Monitor V2 tables (base + rollup + config). +-- Grant to the migration/application role rather than the historical hard-coded +-- "sub2api" role so custom DATABASE_USER deployments work as expected. +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE + channel_monitor_v2_config, + channel_monitor_v2_watermarks, + channel_monitor_v2_metrics_1m, + channel_monitor_v2_user_metrics_1m, + channel_monitor_v2_error_metrics_1m, + channel_monitor_v2_latency_histograms_1m, + channel_monitor_v2_metrics_rollup, + channel_monitor_v2_user_metrics_rollup, + channel_monitor_v2_error_metrics_rollup, + channel_monitor_v2_latency_histograms_rollup +TO CURRENT_USER; diff --git a/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql b/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql new file mode 100644 index 0000000000..4a03f0054f --- /dev/null +++ b/backend/migrations/203_channel_monitor_v2_default_ignore_and_cache.sql @@ -0,0 +1,31 @@ +-- Factory presets for Channel Monitor V2 config: +-- 1) ignored_error_categories: non-ops client/policy failures that should not +-- dominate health error_rate by default (still shown greyed in breakdown). +-- 2) health_thresholds cache floors: 85% watch / 60% critical (higher is better). +-- +-- Only apply when the row still looks like factory empty ignore list and/or +-- zero cache thresholds (operator customizations are left alone). + +UPDATE channel_monitor_v2_config +SET ignored_error_categories = ARRAY[ + 'authentication', + 'client_cancelled', + 'content_policy', + 'context_limit', + 'group_access', + 'model_unsupported', + 'not_found', + 'quota_or_balance' +]::text[] +WHERE id = 1 + AND COALESCE(cardinality(ignored_error_categories), 0) = 0; + +UPDATE channel_monitor_v2_config +SET health_thresholds = health_thresholds + || jsonb_build_object( + 'warning_cache_rate', 0.85, + 'critical_cache_rate', 0.60 + ) +WHERE id = 1 + AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0 + AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0; diff --git a/backend/migrations/204_channel_monitor_hide_throughput.sql b/backend/migrations/204_channel_monitor_hide_throughput.sql new file mode 100644 index 0000000000..194eb7ce60 --- /dev/null +++ b/backend/migrations/204_channel_monitor_hide_throughput.sql @@ -0,0 +1,5 @@ +-- Soft switch: hide RPM/TPM throughput rates on user-facing Channel Monitor V2. +-- Default false (rates visible). Admins always see full metrics. +INSERT INTO settings (key, value) +VALUES ('channel_monitor_hide_throughput', 'false') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql b/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql new file mode 100644 index 0000000000..f30e4e71f0 --- /dev/null +++ b/backend/migrations/205_channel_monitor_v2_reset_factory_cache_thresholds.sql @@ -0,0 +1,13 @@ +-- Keep factory cache scoring tolerant. Migration 203 briefly wrote 0.85/0.60; +-- reset only that exact factory pair, preserving operator-customized values. +UPDATE channel_monitor_v2_config +SET health_thresholds = health_thresholds + || jsonb_build_object( + 'warning_cache_rate', 0, + 'critical_cache_rate', 0 + ) +WHERE id = 1 + AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0.85 + AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0.60 + AND version = 1 + AND updated_by IS NULL; diff --git a/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql b/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql new file mode 100644 index 0000000000..278e90b49a --- /dev/null +++ b/backend/migrations/206_channel_monitor_v2_privacy_defaults.sql @@ -0,0 +1,8 @@ +-- Correct factory defaults introduced by migrations 203 and 204 without +-- mutating those already-applied migration files (which are checksum-locked). +-- The setting was factory-created as false by migration 204. Existing +-- installations need the same privacy-preserving default as new installs. +UPDATE settings +SET value = 'true', updated_at = NOW() +WHERE key = 'channel_monitor_hide_throughput' + AND LOWER(TRIM(value)) IN ('false', '0', 'off', 'disabled'); diff --git a/backend/migrations/217_group_video_model_prices.sql b/backend/migrations/217_group_video_model_prices.sql new file mode 100644 index 0000000000..61080015d4 --- /dev/null +++ b/backend/migrations/217_group_video_model_prices.sql @@ -0,0 +1,8 @@ +-- Per-model-family video per-second prices for Grok Imagine. +-- Shape: {"grok-imagine-video":{"480p":0.05,"720p":0.07},"grok-imagine-video-1.5":{"480p":0.08,"720p":0.14,"1080p":0.25}} +-- Resolution order in billing: per-model map → legacy video_price_* columns → code defaults (model-aware). +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS video_model_prices JSONB; + +COMMENT ON COLUMN groups.video_model_prices IS + '可选:按模型族×分辨率覆盖视频每秒单价 (USD/s)。key 为规范模型族 (grok-imagine-video / grok-imagine-video-1.5),value 为分辨率→单价映射;NULL/空表示不覆盖,回退到 video_price_* 列或官方默认'; diff --git a/backend/migrations/218_group_audio_voice_pricing.sql b/backend/migrations/218_group_audio_voice_pricing.sql new file mode 100644 index 0000000000..c1b8652265 --- /dev/null +++ b/backend/migrations/218_group_audio_voice_pricing.sql @@ -0,0 +1,5 @@ +-- Grok Voice 显式定价:realtime / TTS / STT。 +-- NULL = 使用代码默认单价;显式 0 = 免费;>0 = 分组覆盖价。 +ALTER TABLE groups ADD COLUMN IF NOT EXISTS audio_realtime_price_per_min DECIMAL(20,8); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS audio_tts_price_per_million_chars DECIMAL(20,8); +ALTER TABLE groups ADD COLUMN IF NOT EXISTS audio_stt_price_per_hour DECIMAL(20,8); diff --git a/backend/migrations/219_group_search_price_per_1k.sql b/backend/migrations/219_group_search_price_per_1k.sql new file mode 100644 index 0000000000..54b6ef81f4 --- /dev/null +++ b/backend/migrations/219_group_search_price_per_1k.sql @@ -0,0 +1,3 @@ +-- Grok / 通用搜索工具显式定价(per 1000 calls,USD)。 +-- NULL = 使用代码默认 $10/1k;显式 0 = 免费;>0 = 分组覆盖价。 +ALTER TABLE groups ADD COLUMN IF NOT EXISTS search_price_per_1k DECIMAL(20,8); diff --git a/backend/migrations/220_clear_non_grok_video_generation_config.sql b/backend/migrations/220_clear_non_grok_video_generation_config.sql new file mode 100644 index 0000000000..05571d3709 --- /dev/null +++ b/backend/migrations/220_clear_non_grok_video_generation_config.sql @@ -0,0 +1,42 @@ +-- Videos are Grok/xAI-only. Clear stale video pricing from non-Grok groups. +-- Columns match migrations 170/217 (video_price_* / video_model_prices), not a +-- separate allow_video_generation flag which was never applied on this branch. + +-- Snapshot before clearing. The UPDATE below is irreversible, and an operator +-- who deliberately priced video on a non-Grok group would otherwise lose that +-- configuration with no way to recover it. CREATE TABLE IF NOT EXISTS ... AS +-- SELECT is a no-op on re-run, so this stays idempotent. +CREATE TABLE IF NOT EXISTS groups_video_price_backup_220 AS +SELECT id AS group_id, + platform, + video_price_480p, + video_price_720p, + video_price_1080p, + video_model_prices, + now() AS backed_up_at +FROM groups +WHERE platform IS DISTINCT FROM 'grok' + AND platform IS DISTINCT FROM 'composite' + AND ( + video_price_480p IS NOT NULL + OR video_price_720p IS NOT NULL + OR video_price_1080p IS NOT NULL + OR video_model_prices IS NOT NULL + ); + +COMMENT ON TABLE groups_video_price_backup_220 IS + '迁移 220 清空非 Grok/非 composite 分组视频价前的快照。composite 可能路由到 Grok 账号,予以保留。确认无需回滚后可安全 DROP;回滚方式:UPDATE groups g SET video_price_480p = b.video_price_480p, ... FROM groups_video_price_backup_220 b WHERE g.id = b.group_id'; + +UPDATE groups +SET video_price_480p = NULL, + video_price_720p = NULL, + video_price_1080p = NULL, + video_model_prices = NULL +WHERE platform IS DISTINCT FROM 'grok' + AND platform IS DISTINCT FROM 'composite' + AND ( + video_price_480p IS NOT NULL + OR video_price_720p IS NOT NULL + OR video_price_1080p IS NOT NULL + OR video_model_prices IS NOT NULL + ); 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 21180fb565..7dc445e3fc 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -709,25 +709,14 @@ "tool_use_system_prompt_tokens": 346 }, "codex-auto-review": { - "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, - "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, + "cache_read_input_token_cost": 2e-08, + "input_cost_per_token": 2e-07, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-05, - "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": 1.2e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5066,17 +5055,17 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-terra": { - "cache_creation_input_token_cost": 3.125e-06, - "cache_creation_input_token_cost_batches": 1.5625e-06, - "cache_creation_input_token_cost_flex": 1.5625e-06, - "cache_creation_input_token_cost_priority": 6.25e-06, - "cache_read_input_token_cost": 2.5e-07, - "cache_read_input_token_cost_flex": 1.25e-07, - "cache_read_input_token_cost_priority": 5e-07, - "input_cost_per_token": 2.5e-06, - "input_cost_per_token_batches": 1.25e-06, - "input_cost_per_token_flex": 1.25e-06, - "input_cost_per_token_priority": 5e-06, + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, "long_context_input_token_threshold": 272000, "long_context_input_cost_multiplier": 2.0, "long_context_output_cost_multiplier": 1.5, @@ -5085,10 +5074,10 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 1.5e-05, - "output_cost_per_token_batches": 7.5e-06, - "output_cost_per_token_flex": 7.5e-06, - "output_cost_per_token_priority": 3e-05, + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_batches": 6e-06, + "output_cost_per_token_flex": 6e-06, + "output_cost_per_token_priority": 2.4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5118,17 +5107,17 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-luna": { - "cache_creation_input_token_cost": 1.25e-06, - "cache_creation_input_token_cost_batches": 6.25e-07, - "cache_creation_input_token_cost_flex": 6.25e-07, - "cache_creation_input_token_cost_priority": 2.5e-06, - "cache_read_input_token_cost": 1e-07, - "cache_read_input_token_cost_flex": 5e-08, - "cache_read_input_token_cost_priority": 2e-07, - "input_cost_per_token": 1e-06, - "input_cost_per_token_batches": 5e-07, - "input_cost_per_token_flex": 5e-07, - "input_cost_per_token_priority": 2e-06, + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_batches": 1.25e-07, + "cache_creation_input_token_cost_flex": 1.25e-07, + "cache_creation_input_token_cost_priority": 5e-07, + "cache_read_input_token_cost": 2e-08, + "cache_read_input_token_cost_flex": 1e-08, + "cache_read_input_token_cost_priority": 4e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_batches": 1e-07, + "input_cost_per_token_flex": 1e-07, + "input_cost_per_token_priority": 4e-07, "long_context_input_token_threshold": 272000, "long_context_input_cost_multiplier": 2.0, "long_context_output_cost_multiplier": 1.5, @@ -5137,10 +5126,10 @@ "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 6e-06, - "output_cost_per_token_batches": 3e-06, - "output_cost_per_token_flex": 3e-06, - "output_cost_per_token_priority": 1.2e-05, + "output_cost_per_token": 1.2e-06, + "output_cost_per_token_batches": 6e-07, + "output_cost_per_token_flex": 6e-07, + "output_cost_per_token_priority": 2.4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 88d364a7f3..325520f308 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -103,8 +103,9 @@ RUN addgroup -g 1000 sub2api && \ # Set working directory WORKDIR /app -# Copy binary from builder +# Copy binary and runtime fallback resources from builder COPY --from=backend-builder /app/sub2api /app/sub2api +COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources # Create data directory RUN mkdir -p /app/data && chown -R sub2api:sub2api /app diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e030ae3649..095c550993 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -181,7 +181,7 @@ 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'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.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://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.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 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 证书验证(仅用于调试) @@ -269,6 +269,16 @@ gateway: # # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false + # Stop rewriting load-shed Codex originators to the official CLI identity. + # 关闭「把落在上游降载桶的 Codex originator 改写为官方 CLI 身份(codex_cli_rs)」。 + # + # 上游 /backend-api/codex 按 originator 分桶调度容量:命中降载桶的请求即使 HTTP 200, + # 也会立刻推 server_is_overloaded 错误事件,网关据此判定瞬时上游故障并冷却账号, + # 对外表现为 Codex 账号频繁过载不可用。归一化只替换身份段,保留版本/OS/架构/终端指纹, + # 改写后 originator 与 User-Agent 首段仍然配套。 + # + # 默认 false(即归一化开启);仅当上游调整分桶、使归一化反而落入降载桶时才置 true。 + disable_codex_originator_normalization: false # Enable Codex image-generation bridge injection for /openai/v1/responses. # 是否为 Codex /responses 请求自动注入 image_generation 工具与桥接指令。 # @@ -423,10 +433,28 @@ gateway: fallback_window_seconds: 60 fallback_ttl_seconds: 600 # OpenAI Responses SSE 代理断流熔断;按 proxy_id 跨账号隔离,仅影响下一次调度。 + # 隔离是偏好而非硬闸:当全部候选账号都在被隔离代理下时自动放行(fail-open), + # 不会因熔断而返回"无可用账号"。3 秒内的并发断流合并为一次失败事件。 openai_proxy_stream_circuit: + disabled: false failure_threshold: 2 window_seconds: 60 ttl_seconds: 600 + # Grok free-tier local soft gate (scheduler filter only; admin QueryQuota/import probe bypasses it). + # Enabled by default because free detection requires an explicit subscription_tier/plan_type of "free". + # Stats/query failures fail open so DB issues do not block all Grok traffic. + grok: + # Email/password OAuth is off by default and hidden in the admin UI. + # Setting true enables POST /admin/grok/oauth/password (password → SSO → Build OAuth). + # Prefer SSO cookie, browser OAuth, or refresh_token re-auth in production. + password_auth_enabled: false + free_quota_soft_gate_enabled: true + free_quota_token_limit: 500000 + free_quota_soft_gate_percent: 95 + free_quota_window_hours: 24 + # Stats cache for free-tier soft gate. Hot path never waits on DB: misses + # fail open and refresh in the background. Prefer >= 60s in production. + free_quota_stats_cache_seconds: 60 # HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults) # HTTP 上游连接池配置(HTTP/2 + 多代理场景默认值) # Max idle connections across all hosts diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index db00ac9de8..8603f121db 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -17,6 +17,8 @@ services: NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} container_name: sub2api-dev restart: unless-stopped + security_opt: + - no-new-privileges:true ports: - "${BIND_HOST:-127.0.0.1}:${SERVER_PORT:-8080}:8080" volumes: diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 364fcc2ecc..588862e2f2 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -27,6 +27,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 04a46a8dd5..cd0a008871 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -15,6 +15,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3e1e69a895..68a51e78dd 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -19,6 +19,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/tests/docker-compose-security-test.sh b/deploy/tests/docker-compose-security-test.sh new file mode 100755 index 0000000000..b6cfe768da --- /dev/null +++ b/deploy/tests/docker-compose-security-test.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +check_application_security_opt() { + file=$1 + count=$( + awk ' + $0 == " sub2api:" { + in_application = 1 + next + } + in_application && $0 ~ /^ [A-Za-z0-9_-]+:$/ { + in_application = 0 + } + in_application && $0 == " security_opt:" { + in_security_opt = 1 + next + } + in_application && in_security_opt && $0 == " - no-new-privileges:true" { + count++ + } + END { print count + 0 } + ' "$file" + ) + + if [ "$count" -ne 1 ]; then + printf '%s must enable no-new-privileges exactly once for the sub2api service\n' "$file" >&2 + exit 1 + fi +} + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + check_application_security_opt "$compose_file" +done + +printf 'docker compose security test passed\n' diff --git a/deploy/tests/docker-runtime-resources-test.sh b/deploy/tests/docker-runtime-resources-test.sh new file mode 100755 index 0000000000..4e7480d4d5 --- /dev/null +++ b/deploy/tests/docker-runtime-resources-test.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +fail() { + printf 'docker runtime resources test failed: %s\n' "$1" >&2 + exit 1 +} + +assert_line() { + file=$1 + line=$2 + grep -Fqx "$line" "$file" || fail "$file is missing: $line" +} + +assert_count() { + file=$1 + line=$2 + expected=$3 + actual=$(grep -Fxc "$line" "$file" || true) + [ "$actual" -eq "$expected" ] || fail "$file has $actual occurrences of '$line', expected $expected" +} + +test -s backend/resources/model-pricing/model_prices_and_context_window.json || \ + fail 'fallback pricing data is missing or empty' + +assert_line Dockerfile.goreleaser 'COPY --chown=sub2api:sub2api backend/resources /app/resources' +assert_line deploy/Dockerfile 'COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources' +assert_count .goreleaser.yaml ' - backend/resources' 4 +assert_count .goreleaser.simple.yaml ' - backend/resources' 1 + +printf 'docker runtime resources test passed\n' diff --git a/docs/channel-monitor-v2-safe-defaults.md b/docs/channel-monitor-v2-safe-defaults.md new file mode 100644 index 0000000000..75a9c93969 --- /dev/null +++ b/docs/channel-monitor-v2-safe-defaults.md @@ -0,0 +1,57 @@ +# Channel Monitor V2 Safe Defaults & Gentle Backfill + +**Date:** 2026-08-08 +**Status:** Approved for implementation +**Branch:** `fix/channel-monitor-v2-ops-ui-blockers` (onto channel-monitor-v2) + +## Problem + +1. **H2** — Migration 195 and code defaults set `channel_monitor_mode=v2`, so every upgrade without an existing key auto-opts into passive V2, stops V1 probes, and may leave users on an empty V2 view. +2. **M1** — First-enable bootstrap forces 5s ticks and ~24h `RecomputeRange` chunks until `now-30d`, hammering the primary DB. +3. Error dedup SQL matches `request_id` without a `created_at` bound, scanning `ops_error_logs` history via `candidate_ids`. + +## Decisions + +| Topic | Decision | +|-------|----------| +| Default mode | **v1** (keep active probes). V2 is explicit opt-in. | +| Existing `channel_monitor_mode=v2` rows | **Unchanged** (`ON CONFLICT DO NOTHING` / no force rewrite). | +| Switch to v2 | V1 runner `fire()` and `RunCheck` require `ActiveProbesAllowed()` → probes stop. | +| Backfill | Unified hard gates + adaptive soft gates (same rules, different observed load). | +| Optional profile knob | **Not in this change** (YAGNI). | + +## Mode defaults + +- Migration `195_channel_monitor_mode.sql`: insert `'v1'` when key missing. +- If 195 already applied with old checksum, add migration checksum compatibility (do not force rewrite applied DBs to v1). +- Code: `defaultChannelMonitorMode = v1`; empty/invalid normalize → v1. +- Frontend Settings form default and public feature flag fallback → v1 when missing/invalid. +- Nil settings on V1 `RunCheck` path remains **fail-closed** (no probes) for test safety — independent of product default. + +## Gentle backfill (low-resource phases) + +### Hard gates (all servers) + +- Tick interval = `refresh_interval` (60 or 300s). **No 5s bootstrap override.** +- Each tick: (1) recent overlap (~10m), then (2) **at most one** historical chunk while product bootstrap incomplete / retention walk ongoing. +- Single leader lock; single short transaction budget (~55s). +- Chunk ceiling by depth (product phases 90m → 1d → 7d → 30d → silent 90d). + +### Soft gates (adaptive) + +- Initial historical chunk: **1h** (first seed still **2h** for 90m UI). +- Min chunk: **15m**; max by depth: ≤1d → **2h**, ≤7d → **4h**, older → **6h**. +- Success + fast: grow slowly (×1.5) up to depth max. +- Failure/timeout: halve chunk; exponential backoff on wait (cap 10m). +- Progressive UI: show whatever is covered; bootstrap banner vs 30d product window. + +### Error dedup + +- Bound the `request_id IN candidate_ids` branch with + `created_at >= $1 - INTERVAL '90 minutes' AND created_at < $2`. + +## Out of scope + +- Force existing v2 → v1. +- Separate backfill workers / read replicas. +- Admin `backfill_profile` setting (may add later). diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index afd53708fa..e17969c9cc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -3322,8 +3322,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -8443,7 +8443,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.16: {} + nanoid@3.3.17: {} natural-compare@1.4.0: {} @@ -8641,7 +8641,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/frontend/src/api/__tests__/admin.grok.spec.ts b/frontend/src/api/__tests__/admin.grok.spec.ts index 7560ce443f..87b0a7f7e6 100644 --- a/frontend/src/api/__tests__/admin.grok.spec.ts +++ b/frontend/src/api/__tests__/admin.grok.spec.ts @@ -8,7 +8,7 @@ vi.mock('@/api/client', () => ({ apiClient: { post }, })) -import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok' +import { authorizePassword, createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok' describe('admin Grok SSO import API', () => { beforeEach(() => { @@ -34,4 +34,20 @@ describe('admin Grok SSO import API', () => { { timeout: expectedTimeout }, ) }) + + it('preserves password whitespace and applies the authorization timeout', async () => { + post.mockResolvedValueOnce({ data: { access_token: 'access-token' } }) + + await authorizePassword(' user@example.com ---- password with spaces ', 7) + + expect(post).toHaveBeenCalledWith( + '/admin/grok/oauth/password', + { + email: 'user@example.com', + password: ' password with spaces ', + proxy_id: 7, + }, + { timeout: 120_000 }, + ) + }) }) diff --git a/frontend/src/api/__tests__/admin.system.rollback.spec.ts b/frontend/src/api/__tests__/admin.system.rollback.spec.ts index 15d0989ffb..09eab12b65 100644 --- a/frontend/src/api/__tests__/admin.system.rollback.spec.ts +++ b/frontend/src/api/__tests__/admin.system.rollback.spec.ts @@ -41,7 +41,11 @@ describe('admin system rollback API', () => { const result = await rollback('0.1.146') - expect(post).toHaveBeenCalledWith('/admin/system/rollback', { version: '0.1.146' }) + expect(post).toHaveBeenCalledWith( + '/admin/system/rollback', + { version: '0.1.146' }, + { timeout: 15 * 60 * 1000 } + ) expect(result.need_restart).toBe(true) }) @@ -50,6 +54,10 @@ describe('admin system rollback API', () => { await rollback() - expect(post).toHaveBeenCalledWith('/admin/system/rollback', undefined) + expect(post).toHaveBeenCalledWith( + '/admin/system/rollback', + undefined, + { timeout: 15 * 60 * 1000 } + ) }) }) diff --git a/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts b/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts new file mode 100644 index 0000000000..96913a3842 --- /dev/null +++ b/frontend/src/api/__tests__/auth-captcha-oauth-start.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const post = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/client', () => ({ + apiClient: { post } +})) + +import { + buildOAuthLoginStartURL, + startOAuthLogin, + type OAuthLoginStart +} from '@/api/auth' + +describe('OAuth captcha start API', () => { + beforeEach(() => { + post.mockReset() + }) + + it('posts Tencent captcha proof and preserves OAuth query parameters', async () => { + const request: OAuthLoginStart = { + provider: 'github', + params: { redirect: '/dashboard', aff_code: 'AFF123' } + } + const proof = { + tencent_captcha_ticket: 'ticket', + tencent_captcha_randstr: '@rand' + } + post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } }) + + await expect(startOAuthLogin(request, proof)).resolves.toEqual({ + authorize_url: 'https://github.com/login/oauth/authorize' + }) + expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, { + params: request.params + }) + }) + + it('builds the legacy GET start URL when Tencent captcha is disabled', () => { + expect(buildOAuthLoginStartURL({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + })).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro') + }) +}) diff --git a/frontend/src/api/__tests__/channelMonitorV2.spec.ts b/frontend/src/api/__tests__/channelMonitorV2.spec.ts new file mode 100644 index 0000000000..403e0739d6 --- /dev/null +++ b/frontend/src/api/__tests__/channelMonitorV2.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../client' +import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2' + +afterEach(() => vi.restoreAllMocks()) + +describe('channel monitor V2 query serialization', () => { + it('uses repeated keys without bracket suffixes for array filters', () => { + const query = repeatedArrayParamsSerializer({ + range: '90m', + platform: ['openai', 'grok'], + group_id: [1, 2], + model: undefined, + group_by: 'platform_group_model', + }) + + expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model') + expect(query).not.toContain('%5B%5D') + }) + + it('sends the matrix grouping with the shared filters', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ + data: { coverage: {}, group_by: 'platform_group', items: [] }, + }) + + await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true) + + expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({ + params: { + range: '24h', + platform: ['openai'], + group_id: [7], + model: undefined, + group_by: 'platform_group', + }, + })) + }) +}) diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index d1b619027e..b095c7f375 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -344,6 +344,94 @@ describe('API Client', () => { writable: true, }) }) + + it('有 refresh_token 时刷新并重试原请求', async () => { + localStorage.setItem('auth_token', 'expired-token') + localStorage.setItem('refresh_token', 'refresh-token') + localStorage.setItem('token_expires_at', String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + + const adapter = vi.fn() + .mockRejectedValueOnce({ + response: { + status: 401, + data: { code: 'TOKEN_EXPIRED', message: 'Token expired' }, + }, + config: { + url: '/test', + headers: { Authorization: 'Bearer expired-token' }, + }, + code: 'ERR_BAD_REQUEST', + }) + .mockResolvedValueOnce({ + status: 200, + data: { code: 0, data: { ok: true } }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + vi.spyOn(axios, 'post').mockResolvedValueOnce({ + data: { + code: 0, + message: 'ok', + data: { + access_token: 'new-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + }, + }, + }) + + await expect(apiClient.get('/test')).resolves.toMatchObject({ data: { ok: true } }) + + expect(adapter).toHaveBeenCalledTimes(2) + expect(localStorage.getItem('auth_token')).toBe('new-token') + expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token') + expect(adapter.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer new-token') + }) + + it('刷新期间换号时旧请求不会清除新会话', async () => { + localStorage.setItem('auth_token', 'user-a-access') + localStorage.setItem('refresh_token', 'user-a-refresh') + localStorage.setItem('token_expires_at', String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + + apiClient.defaults.adapter = vi.fn().mockRejectedValueOnce({ + response: { + status: 401, + data: { code: 'TOKEN_EXPIRED', message: 'Token expired' }, + }, + config: { + url: '/test', + headers: { Authorization: 'Bearer user-a-access' }, + }, + code: 'ERR_BAD_REQUEST', + }) + + let rejectRefresh!: (reason: Error) => void + vi.spyOn(axios, 'post').mockImplementationOnce( + () => new Promise((_resolve, reject) => { + rejectRefresh = reject + }) + ) + + const staleRequest = apiClient.get('/test') + await vi.waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)) + + localStorage.setItem('auth_token', 'user-b-access') + localStorage.setItem('refresh_token', 'user-b-refresh') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) + rejectRefresh(new Error('stale refresh failed')) + + await expect(staleRequest).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' }) + expect(localStorage.getItem('auth_token')).toBe('user-b-access') + expect(localStorage.getItem('refresh_token')).toBe('user-b-refresh') + expect(localStorage.getItem('auth_user')).toBe(JSON.stringify({ id: 8 })) + expect(window.location.pathname).toBe('/') + }) }) // --- 网络错误 --- diff --git a/frontend/src/api/__tests__/passkey.spec.ts b/frontend/src/api/__tests__/passkey.spec.ts index fe32c1c7cd..52ab5c477f 100644 --- a/frontend/src/api/__tests__/passkey.spec.ts +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -97,6 +97,7 @@ describe('passkey api', () => { await passkeyAPI.login() + expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin') const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3]) expect(request.publicKey!.userVerification).toBe('required') @@ -119,6 +120,38 @@ describe('passkey api', () => { }) }) + it('sends Tencent captcha proof only with the passkey begin request', async () => { + post + .mockResolvedValueOnce({ + data: { + session_token: 'one-time-session', + options: { + publicKey: { + challenge: 'AQID', + rpId: 'sub2api.example.com', + userVerification: 'required' + } + } + } + }) + .mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } }) + credentialGet.mockResolvedValue(new FakePublicKeyCredential()) + + await passkeyAPI.login({ + tencent_captcha_ticket: 'ticket-value', + tencent_captcha_randstr: '@rand-value' + }) + + expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', { + tencent_captcha_ticket: 'ticket-value', + tencent_captcha_randstr: '@rand-value' + }) + expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({ + tencent_captcha_ticket: expect.anything(), + tencent_captcha_randstr: expect.anything() + })) + }) + it('sends the account password when beginning registration', async () => { post .mockResolvedValueOnce({ diff --git a/frontend/src/api/__tests__/tokenRefresh.spec.ts b/frontend/src/api/__tests__/tokenRefresh.spec.ts new file mode 100644 index 0000000000..a7fee8c643 --- /dev/null +++ b/frontend/src/api/__tests__/tokenRefresh.spec.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import axios from 'axios' + +vi.mock('axios', () => ({ + default: { + post: vi.fn() + } +})) + +const mockedPost = vi.mocked(axios.post) + +function seedSession(overrides: Partial> = {}): void { + localStorage.setItem('auth_token', overrides.auth_token || 'old-access') + localStorage.setItem('refresh_token', overrides.refresh_token || 'old-refresh') + localStorage.setItem('token_expires_at', overrides.token_expires_at || String(Date.now() - 1)) + localStorage.setItem('auth_user', JSON.stringify({ id: 7, email: 'admin@example.com' })) +} + +function refreshedResponse() { + return { + data: { + code: 0, + message: 'ok', + data: { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + token_type: 'Bearer' + } + } + } +} + +describe('refreshAuthTokens', () => { + beforeEach(() => { + localStorage.clear() + mockedPost.mockReset() + vi.resetModules() + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: undefined + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shares one refresh request between concurrent callers in the same document', async () => { + seedSession() + let resolveRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const first = refreshAuthTokens({ failedAccessToken: 'old-access' }) + const second = refreshAuthTokens({ failedAccessToken: 'old-access' }) + + expect(mockedPost).toHaveBeenCalledTimes(1) + resolveRequest(refreshedResponse()) + + await expect(first).resolves.toMatchObject({ access_token: 'new-access' }) + await expect(second).resolves.toMatchObject({ refresh_token: 'new-refresh' }) + expect(localStorage.getItem('refresh_token')).toBe('new-refresh') + }) + + it('adopts tokens refreshed by another tab after acquiring the Web Lock', async () => { + seedSession() + const request = vi.fn(async (_name: string, callback: () => Promise) => { + localStorage.setItem('auth_token', 'peer-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'peer-refresh') + return callback() + }) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request } + }) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const result = await refreshAuthTokens({ failedAccessToken: 'old-access' }) + + expect(request).toHaveBeenCalledTimes(1) + expect(mockedPost).not.toHaveBeenCalled() + expect(result).toMatchObject({ + access_token: 'peer-access', + refresh_token: 'peer-refresh' + }) + }) + + it('recovers when a peer publishes the rotated token just after this request fails', async () => { + seedSession() + mockedPost.mockRejectedValueOnce(new Error('refresh token already used')) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + window.setTimeout(() => { + localStorage.setItem('auth_token', 'peer-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'peer-refresh') + }, 10) + + await expect( + refreshAuthTokens({ failedAccessToken: 'old-access' }) + ).resolves.toMatchObject({ + access_token: 'peer-access', + refresh_token: 'peer-refresh' + }) + }) + + it('waits for a slow peer after losing a refresh-token race without Web Locks', async () => { + vi.useFakeTimers() + seedSession() + let resolveWinningRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveWinningRequest = resolve + }) + ) + const firstTab = await import('@/api/tokenRefresh') + vi.resetModules() + const secondTab = await import('@/api/tokenRefresh') + + const winner = firstTab.refreshAuthTokens({ failedAccessToken: 'old-access' }) + mockedPost.mockRejectedValueOnce({ response: { status: 401 } }) + const loser = secondTab.refreshAuthTokens({ failedAccessToken: 'old-access' }) + + window.setTimeout(() => resolveWinningRequest(refreshedResponse()), 1_500) + await vi.advanceTimersByTimeAsync(1_600) + + await expect(winner).resolves.toMatchObject({ access_token: 'new-access' }) + await expect(loser).resolves.toMatchObject({ refresh_token: 'new-refresh' }) + expect(mockedPost).toHaveBeenCalledTimes(2) + expect(localStorage.getItem('refresh_token')).toBe('new-refresh') + }) + + it('does not adopt a token from a different signed-in user', async () => { + vi.useFakeTimers() + seedSession() + mockedPost.mockRejectedValueOnce(new Error('refresh token already used')) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + window.setTimeout(() => { + localStorage.setItem('auth_user', JSON.stringify({ id: 8, email: 'other@example.com' })) + localStorage.setItem('auth_token', 'other-access') + localStorage.setItem('token_expires_at', String(Date.now() + 3600_000)) + localStorage.setItem('refresh_token', 'other-refresh') + }, 10) + + const rejection = expect( + refreshAuthTokens({ failedAccessToken: 'old-access' }) + ).rejects.toThrow('refresh token already used') + await vi.advanceTimersByTimeAsync(1_100) + await rejection + }) + + it('does not restore a session that was logged out while refresh was in flight', async () => { + vi.useFakeTimers() + seedSession() + let resolveRequest!: (value: ReturnType) => void + mockedPost.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRequest = resolve + }) + ) + const { refreshAuthTokens } = await import('@/api/tokenRefresh') + + const pending = refreshAuthTokens({ failedAccessToken: 'old-access' }) + localStorage.clear() + resolveRequest(refreshedResponse()) + + const rejection = expect(pending).rejects.toThrow('Session changed during token refresh') + await vi.advanceTimersByTimeAsync(1_100) + await rejection + expect(localStorage.getItem('auth_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 40b80c1861..21ce9f092f 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -317,6 +317,19 @@ export async function getUsage(id: number, source?: 'passive' | 'active', force? return data } +export interface BatchAccountUsageResponse { + usage: Record + errors: Record +} + +export async function getBatchUsage(accountIds: number[], force?: boolean): Promise { + const { data } = await apiClient.post('/admin/accounts/usage/batch', { + account_ids: accountIds, + force: force === true + }) + return data +} + /** * Clear account rate limit status * @param id - Account ID @@ -715,6 +728,8 @@ export interface BatchOperationResult { total: number success: number failed: number + success_ids?: number[] + failed_ids?: number[] errors?: Array<{ account_id: number; error: string }> warnings?: Array<{ account_id: number; warning: string }> } @@ -729,6 +744,16 @@ export async function revertProxyFallback(id: number): Promise<{ message: string return data } +/** + * Delete multiple accounts with bounded server-side concurrency. + */ +export async function batchDelete(accountIds: number[]): Promise { + const { data } = await apiClient.post('/admin/accounts/batch-delete', { + account_ids: accountIds + }) + return data +} + /** * Batch clear account errors * @param accountIds - Array of account IDs @@ -822,21 +847,51 @@ export interface OpenAIQuotaResetResult { code: string credit?: OpenAIQuotaResetCredit | null windows_reset: number + quota?: OpenAIQuotaUsage | null + account?: Account | null + cache_refreshed: boolean + account_state_recovered: boolean + warning_code?: + | 'reset_credit_cache_refresh_failed' + | 'account_state_recovery_failed' + | 'account_state_refresh_failed' +} + +/** Usage payload plus whether the reset-credit snapshot was persisted. */ +export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage { + cache_persisted: boolean } /** - * Query OpenAI/Codex rate-limit usage for an OAuth account. + * Query the upstream quota AND persist the reset-credit snapshot on the account + * so the card can be rehydrated without an upstream round-trip. It is a POST + * because it writes account state (and must therefore be audited). + * + * The read-only `GET /admin/openai/accounts/:id/quota` endpoint still exists for + * API consumers; the panel always wants the snapshot persisted, so it has no + * client binding here. */ -export async function queryOpenAIQuota(id: number): Promise { - const { data } = await apiClient.get(`/admin/openai/accounts/${id}/quota`) +export async function refreshOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/quota/refresh` + ) return data } /** * Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account. + * + * The credit is non-refundable and the endpoint chains an upstream reset with an + * upstream re-query, so it needs a larger budget than the default client + * timeout: aborting locally would report a successful consumption as a failure + * and invite a retry that spends a second credit. */ export async function resetOpenAIQuota(id: number): Promise { - const { data } = await apiClient.post(`/admin/openai/accounts/${id}/reset-quota`) + const { data } = await apiClient.post( + `/admin/openai/accounts/${id}/reset-quota`, + undefined, + { timeout: 90_000 } + ) return data } @@ -944,6 +999,7 @@ export const accountsAPI = { getStats, clearError, getUsage, + getBatchUsage, getTodayStats, getBatchTodayStats, clearRateLimit, @@ -968,11 +1024,12 @@ export const accountsAPI = { importCodexSession, createOpenAICodexPAT, getAntigravityDefaultModelMapping, + batchDelete, batchClearError, batchRefresh, setPrivacy, revertProxyFallback, - queryOpenAIQuota, + refreshOpenAIQuota, resetOpenAIQuota, createSparkShadow, getUpstreamBillingProbeSettings, diff --git a/frontend/src/api/admin/backup.ts b/frontend/src/api/admin/backup.ts index f8e978b007..25a6e5bd9c 100644 --- a/frontend/src/api/admin/backup.ts +++ b/frontend/src/api/admin/backup.ts @@ -23,6 +23,7 @@ export interface BackupRecord { backup_type: string file_name: string s3_key: string + parts?: BackupPart[] size_bytes: number triggered_by: string error_message?: string @@ -35,6 +36,24 @@ export interface BackupRecord { restored_at?: string } +export interface BackupPart { + index: number + s3_key: string + size_bytes: number + sha256?: string +} + +export interface BackupDownloadPart { + index: number + size_bytes: number + url: string +} + +export interface BackupDownloadResponse { + url?: string + parts?: BackupDownloadPart[] +} + export interface CreateBackupRequest { expire_days?: number } @@ -137,8 +156,8 @@ export async function deleteBackup(id: string): Promise { await apiClient.delete(`/admin/backups/${id}`) } -export async function getDownloadURL(id: string): Promise<{ url: string }> { - const { data } = await apiClient.get<{ url: string }>(`/admin/backups/${id}/download-url`) +export async function getDownloadURL(id: string): Promise { + const { data } = await apiClient.get(`/admin/backups/${id}/download-url`) return data } diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index ae20d33f8e..5117039f61 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -56,6 +56,7 @@ export interface TrendParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface TrendResponse { @@ -87,6 +88,7 @@ export interface ModelStatsParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface ModelStatsResponse { @@ -115,6 +117,7 @@ export interface GroupStatsParams { request_type?: UsageRequestType stream?: boolean billing_type?: number | null + upstream_model_mismatch?: boolean } export interface GroupStatsResponse { diff --git a/frontend/src/api/admin/grok.ts b/frontend/src/api/admin/grok.ts index f59c05c1d7..c1dc1cbaa2 100644 --- a/frontend/src/api/admin/grok.ts +++ b/frontend/src/api/admin/grok.ts @@ -19,6 +19,17 @@ export interface GrokAuthUrlRequest { redirect_uri?: string } +export interface GrokOAuthCapabilities { + password_auth_enabled: boolean +} + +const GROK_AUTHORIZATION_TIMEOUT_MS = 120_000 + +export async function getCapabilities(): Promise { + const { data } = await apiClient.get('/admin/grok/oauth/capabilities') + return data +} + export interface GrokExchangeCodeRequest { session_id: string state: string @@ -170,4 +181,48 @@ export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise { + const payload: Record = { sso_token: ssoToken } + if (proxyId) payload.proxy_id = proxyId + const { data } = await apiClient.post('/admin/grok/oauth/sso-token', payload, { + timeout: GROK_AUTHORIZATION_TIMEOUT_MS + }) + return data +} + +/** + * Password login → ephemeral SSO → Build OAuth. + * Password is only sent over the wire for this call; never persist it in credentials. + */ +export async function authorizePassword( + emailAndPassword: string, + proxyId?: number | null +): Promise { + // Format: email----password (password may contain dashes). + const sep = '----' + const idx = emailAndPassword.indexOf(sep) + const email = (idx >= 0 ? emailAndPassword.slice(0, idx) : emailAndPassword).trim() + const password = idx >= 0 ? emailAndPassword.slice(idx + sep.length) : '' + const payload: Record = { email, password } + if (proxyId) payload.proxy_id = proxyId + const { data } = await apiClient.post('/admin/grok/oauth/password', payload, { + timeout: GROK_AUTHORIZATION_TIMEOUT_MS + }) + return data +} + +export default { + generateAuthUrl, + getCapabilities, + exchangeCode, + refreshGrokToken, + queryQuota, + resetQuota, + createFromSSO, + validateSSOToken, + authorizePassword, +} diff --git a/frontend/src/api/admin/riskControl.ts b/frontend/src/api/admin/riskControl.ts index d96845d1a0..64287e71f6 100644 --- a/frontend/src/api/admin/riskControl.ts +++ b/frontend/src/api/admin/riskControl.ts @@ -14,6 +14,7 @@ export interface ContentModerationConfig { mode: ModerationMode base_url: string model: string + proxy_id: number | null api_key_configured: boolean api_key_masked: string api_key_count: number @@ -66,6 +67,8 @@ export interface TestContentModerationAPIKeysPayload { base_url?: string model?: string timeout_ms?: number + // null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理 + proxy_id?: number prompt?: string images?: string[] } @@ -90,6 +93,8 @@ export interface UpdateContentModerationConfig { mode?: ModerationMode base_url?: string model?: string + // undefined 不修改;0 清除(直连);>0 指定代理 + proxy_id?: number api_key?: string api_keys?: string[] api_keys_mode?: 'append' | 'replace' diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 2fceea7b62..b176024ae6 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -32,6 +32,38 @@ export type DefaultPlatformQuotasMap = Partial + +export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ + "openai", + "anthropic", + "grok", +] + +export function normalizeAccountSchedulingThresholdsMap( + input?: Partial> | null, +): AccountSchedulingThresholdsMap { + const result = {} as AccountSchedulingThresholdsMap + for (const platform of SCHEDULING_THRESHOLD_PLATFORMS) { + const value = input?.[platform] + result[platform] = typeof value === "number" && Number.isFinite(value) + ? Math.min(100, Math.max(1, Math.trunc(value))) + : 100 + } + return result +} + +export function sanitizeAccountSchedulingThresholdsMap( + input?: Partial> | null, +): AccountSchedulingThresholdsMap { + return normalizeAccountSchedulingThresholdsMap(input) +} + /** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */ export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap { const result: DefaultPlatformQuotasMap = {} @@ -360,6 +392,7 @@ export interface SystemSettings { registration_enabled: boolean; email_verify_enabled: boolean; registration_email_suffix_whitelist: string[]; + registration_email_domain_quota_enabled: boolean; promo_code_enabled: boolean; password_reset_enabled: boolean; frontend_url: string; @@ -440,6 +473,7 @@ export interface SystemSettings { contact_info: string; doc_url: string; home_content: string; + compact_home_enabled: boolean; hide_ccs_import_button: boolean; table_default_page_size: number; table_page_size_options: number[]; @@ -458,6 +492,18 @@ export interface SystemSettings { turnstile_enabled: boolean; turnstile_site_key: string; turnstile_secret_key_configured: boolean; + tencent_captcha_enabled: boolean; + tencent_captcha_app_id: string; + tencent_captcha_app_secret_key_configured: boolean; + tencent_captcha_cloud_secret_id_configured: boolean; + tencent_captcha_cloud_secret_key_configured: boolean; + tencent_captcha_region: string; + aliyun_captcha_enabled: boolean; + aliyun_captcha_access_key_id: string; + aliyun_captcha_access_key_secret_configured: boolean; + aliyun_captcha_scene_id: string; + aliyun_captcha_prefix: string; + aliyun_captcha_region: string; api_key_acl_trust_forwarded_ip: boolean; forwarded_client_ip_headers: string[]; @@ -543,6 +589,12 @@ export interface SystemSettings { fallback_model_openai: string; fallback_model_gemini: string; fallback_model_antigravity: string; + grok_default_text_model: string; + grok_cross_client_model_map_enabled: boolean; + grok_default_base_url_mode: string; + + // Per-platform account auto-pause thresholds (100 = disabled) + account_scheduling_thresholds: AccountSchedulingThresholdsMap; // Identity patch configuration (Claude -> Gemini) enable_identity_patch: boolean; @@ -573,6 +625,9 @@ export interface SystemSettings { enable_client_dateline_normalization: boolean; antigravity_user_agent_version: string; openai_codex_user_agent: string; + openai_codex_client_version: string; + openai_codex_client_version_synced: string; + openai_codex_version_auto_sync_enabled: boolean; // codex_cli_only 加固 min_codex_version: string; max_codex_version: string; @@ -654,7 +709,9 @@ export interface SystemSettings { // Channel Monitor feature switch channel_monitor_enabled: boolean; + channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds: number; + channel_monitor_hide_throughput?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -678,6 +735,7 @@ export interface UpdateSettingsRequest { registration_enabled?: boolean; email_verify_enabled?: boolean; registration_email_suffix_whitelist?: string[]; + registration_email_domain_quota_enabled?: boolean; promo_code_enabled?: boolean; password_reset_enabled?: boolean; frontend_url?: string; @@ -752,6 +810,7 @@ export interface UpdateSettingsRequest { contact_info?: string; doc_url?: string; home_content?: string; + compact_home_enabled?: boolean; hide_ccs_import_button?: boolean; table_default_page_size?: number; table_page_size_options?: number[]; @@ -768,6 +827,18 @@ export interface UpdateSettingsRequest { turnstile_enabled?: boolean; turnstile_site_key?: string; turnstile_secret_key?: string; + tencent_captcha_enabled?: boolean; + tencent_captcha_app_id?: string; + tencent_captcha_app_secret_key?: string; + tencent_captcha_cloud_secret_id?: string; + tencent_captcha_cloud_secret_key?: string; + tencent_captcha_region?: string; + aliyun_captcha_enabled?: boolean; + aliyun_captcha_access_key_id?: string; + aliyun_captcha_access_key_secret?: string; + aliyun_captcha_scene_id?: string; + aliyun_captcha_prefix?: string; + aliyun_captcha_region?: string; api_key_acl_trust_forwarded_ip?: boolean; forwarded_client_ip_headers?: string[]; linuxdo_connect_enabled?: boolean; @@ -843,6 +914,10 @@ export interface UpdateSettingsRequest { fallback_model_openai?: string; fallback_model_gemini?: string; fallback_model_antigravity?: string; + grok_default_text_model?: string; + grok_cross_client_model_map_enabled?: boolean; + grok_default_base_url_mode?: string; + account_scheduling_thresholds?: AccountSchedulingThresholdsMap; enable_identity_patch?: boolean; identity_patch_prompt?: string; ops_monitoring_enabled?: boolean; @@ -863,6 +938,8 @@ export interface UpdateSettingsRequest { enable_client_dateline_normalization?: boolean; antigravity_user_agent_version?: string; openai_codex_user_agent?: string; + openai_codex_client_version?: string; + openai_codex_version_auto_sync_enabled?: boolean; // codex_cli_only 加固 min_codex_version?: string; max_codex_version?: string; @@ -930,7 +1007,9 @@ export interface UpdateSettingsRequest { // Channel Monitor feature switch channel_monitor_enabled?: boolean; + channel_monitor_mode?: 'v1' | 'v2'; channel_monitor_default_interval_seconds?: number; + channel_monitor_hide_throughput?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 83be033c11..1c740caabe 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams { user_id?: number exact_total?: boolean billing_mode?: string + upstream_model_mismatch?: boolean sort_by?: string sort_order?: 'asc' | 'desc' // 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象) @@ -123,6 +124,7 @@ export async function getStats(params: { model?: string request_type?: UsageRequestType stream?: boolean + upstream_model_mismatch?: boolean period?: string start_date?: string end_date?: string diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fd25923067..6f880e3482 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -4,6 +4,8 @@ */ import { apiClient } from './client' +import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh' +export type { RefreshTokenResponse } from './tokenRefresh' import type { LoginRequest, RegisterRequest, @@ -12,6 +14,7 @@ import type { SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings, + ActionCaptchaRequestProof, TotpLoginResponse, TotpLogin2FARequest } from '@/types' @@ -21,6 +24,43 @@ import type { */ export type LoginResponse = AuthResponse | TotpLoginResponse +export type OAuthLoginProvider = + | 'github' + | 'google' + | 'linuxdo' + | 'dingtalk' + | 'wechat' + | 'oidc' + +export interface OAuthLoginStart { + provider: OAuthLoginProvider + params: Record +} + +export interface OAuthLoginStartResponse { + authorize_url: string +} + +export function buildOAuthLoginStartURL(request: OAuthLoginStart): string { + const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' + const normalized = apiBase.replace(/\/$/, '') + const query = new URLSearchParams(request.params).toString() + const path = `${normalized}/auth/oauth/${request.provider}/start` + return query ? `${path}?${query}` : path +} + +export async function startOAuthLogin( + request: OAuthLoginStart, + proof: ActionCaptchaRequestProof +): Promise { + const { data } = await apiClient.post( + `/auth/oauth/${request.provider}/start`, + proof, + { params: request.params } + ) + return data +} + /** * Type guard to check if login response requires 2FA */ @@ -179,13 +219,6 @@ export async function logout(): Promise { /** * Refresh token response */ -export interface RefreshTokenResponse { - access_token: string - refresh_token: string - expires_in: number - token_type: string -} - export interface OAuthTokenResponse { access_token: string refresh_token?: string @@ -293,21 +326,7 @@ export async function prepareOAuthBindAccessTokenCookie(): Promise { * @returns New token pair */ export async function refreshToken(): Promise { - const currentRefreshToken = getRefreshToken() - if (!currentRefreshToken) { - throw new Error('No refresh token available') - } - - const { data } = await apiClient.post('/auth/refresh', { - refresh_token: currentRefreshToken - }) - - // Update tokens in localStorage - setAuthToken(data.access_token) - setRefreshToken(data.refresh_token) - setTokenExpiresAt(data.expires_in) - - return data + return refreshAuthTokens() } /** @@ -512,6 +531,8 @@ export async function validateInvitationCode(code: string): Promise + group_ids: number[] + health_thresholds: { + minimum_sample: number + warning_error_rate: number + critical_error_rate: number + target_ttft_ms: number + warning_ttft_ms: number + critical_ttft_ms: number + warning_cache_rate: number + critical_cache_rate: number + error_weight: number + ttft_weight: number + cache_weight: number + } + /** Categories excluded from error_rate / health; still listed in error breakdown. */ + ignored_error_categories?: string[] +} + +/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */ +export const MONITOR_ERROR_CATEGORIES = [ + 'content_policy', + 'authentication', + 'context_limit', + 'invalid_request', + 'model_unsupported', + 'group_access', + 'quota_or_balance', + 'account_pool_unavailable', + 'rate_or_capacity', + 'timeout', + 'transport_or_stream', + 'upstream_forbidden', + 'not_found', + 'client_cancelled', + 'upstream_5xx', + 'internal', + 'other', +] as const + +export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number] + +export interface MonitorSnapshot { + config: MonitorConfig + coverage: MonitorCoverage + metrics: MonitorMetric + health: MonitorHealth + trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }> +} + +export interface MonitorMatrixBucket { + bucket_start: string + metrics: MonitorMetric + health: MonitorHealth +} + +export interface MonitorMatrixRow { + platform: string + group_id?: number + group_name?: string + model?: string + metrics: MonitorMetric + health: MonitorHealth + buckets: MonitorMatrixBucket[] +} + +export interface MonitorMatrixResponse { + coverage: MonitorCoverage + group_by: MonitorMatrixGroupBy + items: MonitorMatrixRow[] +} + +export interface MonitorDimensions { + platforms: Array<{ value: string; label: string; request_count: number }> + groups: Array<{ id: number; name: string; platform?: string; request_count: number }> + models: Array<{ value: string; label: string; platform?: string; request_count: number }> +} + +export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth } +export interface MonitorErrorRow { + category: string + count: number + rate: number + details?: Array<{ + platform?: string + model?: string + error_type?: string + status_code?: number + upstream_status_code?: number + message?: string + count: number + }> + /** true when category is in config.ignored_error_categories */ + ignored?: boolean +} +export interface MonitorUserRow { + user_id?: number + rank: number + email?: string + username?: string + display_label: string + is_self: boolean + can_drilldown: boolean + metrics: MonitorMetric +} + +function params(filter: MonitorFilter) { + return { + range: filter.range, + platform: filter.platforms.length ? filter.platforms : undefined, + group_id: filter.groupIds.length ? filter.groupIds : undefined, + model: filter.models.length ? filter.models : undefined, + } +} + +export function repeatedArrayParamsSerializer(values: Record): string { + const search = new URLSearchParams() + for (const [key, rawValue] of Object.entries(values)) { + if (rawValue == null || rawValue === '') continue + const entries = Array.isArray(rawValue) ? rawValue : [rawValue] + for (const value of entries) { + if (value != null && value !== '') search.append(key, String(value)) + } + } + return search.toString() +} + +const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record = {}) => ({ + params: { ...params(filter), ...extraParams }, + paramsSerializer: { serialize: repeatedArrayParamsSerializer }, + signal, +}) + +function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' } + +export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/dimensions`, requestConfig(filter, signal)) + return data +} +export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/snapshot`, requestConfig(filter, signal)) + return data +} +export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy })) + return data +} +export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal)) + return data +} +export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal)) + return data +} +export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal)) + return data +} +export async function getConfig() { + const { data } = await apiClient.get('/admin/channel-monitor-v2/config') + return data +} +export async function updateConfig(config: MonitorConfig) { + const { data } = await apiClient.put('/admin/channel-monitor-v2/config', config) + return data +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8375cf8763..1f4e4d6134 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -12,6 +12,7 @@ import { shouldMarkAdminUIRequest, shouldMarkUserUIRequest, } from './adminUIRequest' +import { refreshAuthTokens } from './tokenRefresh' import { getAPIBaseURL } from './url' export { buildApiUrl, buildGatewayUrl } from './url' @@ -26,28 +27,6 @@ export const apiClient: AxiosInstance = axios.create({ } }) -// ==================== Token Refresh State ==================== - -// Track if a token refresh is in progress to prevent multiple simultaneous refresh requests -let isRefreshing = false -// Queue of requests waiting for token refresh -let refreshSubscribers: Array<(token: string) => void> = [] - -/** - * Subscribe to token refresh completion - */ -function subscribeTokenRefresh(callback: (token: string) => void): void { - refreshSubscribers.push(callback) -} - -/** - * Notify all subscribers that token has been refreshed - */ -function onTokenRefreshed(token: string): void { - refreshSubscribers.forEach((callback) => callback(token)) - refreshSubscribers = [] -} - // ==================== Request Interceptor ==================== // Get user's timezone @@ -190,74 +169,36 @@ apiClient.interceptors.response.use( // If we have a refresh token and this is not an auth endpoint, try to refresh if (refreshToken && !isAuthEndpoint) { - if (isRefreshing) { - // Wait for the ongoing refresh to complete - return new Promise((resolve, reject) => { - subscribeTokenRefresh((newToken: string) => { - if (newToken) { - // Mark as retried to prevent infinite loop if retry also returns 401 - originalRequest._retry = true - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${newToken}` - } - resolve(apiClient(originalRequest)) - } else { - // Refresh failed, reject with original error - reject({ - status, - code: apiData.code, - message: apiData.message || apiData.detail || error.message - }) - } - }) - }) - } - + const refreshSessionUser = localStorage.getItem('auth_user') originalRequest._retry = true - isRefreshing = true try { - // Call refresh endpoint directly to avoid circular dependency - const refreshResponse = await axios.post( - `${getAPIBaseURL()}/auth/refresh`, - { refresh_token: refreshToken }, - // 显式设置超时:裸 axios 默认无限等待,若刷新请求挂起会导致 isRefreshing - // 永远为 true,所有排队的 401 重试请求永久卡死,页面 loading 无法恢复。 - { headers: { 'Content-Type': 'application/json' }, timeout: 30000 } - ) + const headers = originalRequest.headers as Record | undefined + const authHeader = headers?.Authorization ?? headers?.authorization + const failedAccessToken = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : null + const tokens = await refreshAuthTokens({ failedAccessToken }) - const refreshData = refreshResponse.data as ApiResponse<{ - access_token: string - refresh_token: string - expires_in: number - }> - - if (refreshData.code === 0 && refreshData.data) { - const { access_token, refresh_token: newRefreshToken, expires_in } = refreshData.data - - // Update tokens in localStorage (convert expires_in to timestamp) - localStorage.setItem('auth_token', access_token) - localStorage.setItem('refresh_token', newRefreshToken) - localStorage.setItem('token_expires_at', String(Date.now() + expires_in * 1000)) - - // Notify subscribers with new token - onTokenRefreshed(access_token) - - // Retry the original request with new token - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${access_token}` - } - - isRefreshing = false - return apiClient(originalRequest) + // Retry the original request with the refreshed token + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${tokens.access_token}` + } + return apiClient(originalRequest) + } catch { + // A stale request must never destroy a session that was logged out or replaced while + // its refresh was in flight (for example, when another tab signs in as another user). + const sessionChanged = + localStorage.getItem('refresh_token') !== refreshToken || + localStorage.getItem('auth_user') !== refreshSessionUser + if (sessionChanged) { + return Promise.reject({ + status: 401, + code: 'AUTH_SESSION_CHANGED', + message: 'Authentication session changed while refreshing.' + }) } - - // Refresh response was not successful, fall through to clear auth - throw new Error('Token refresh failed') - } catch (refreshError) { - // Refresh failed - notify subscribers with empty token - onTokenRefreshed('') - isRefreshing = false // Clear tokens and redirect to login localStorage.removeItem('auth_token') diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 741f2bb0e1..c17b3aa1a7 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -40,6 +40,9 @@ export interface ModelPlazaGroup { peak_end: string peak_rate_multiplier: number is_exclusive: boolean + /** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */ + image_rate_independent: boolean + image_rate_multiplier: number models: PlazaModel[] } diff --git a/frontend/src/api/passkey.ts b/frontend/src/api/passkey.ts index fdfe128a21..5d010aeb5e 100644 --- a/frontend/src/api/passkey.ts +++ b/frontend/src/api/passkey.ts @@ -1,5 +1,5 @@ import { apiClient } from './client' -import type { AuthResponse } from '@/types' +import type { ActionCaptchaRequestProof, AuthResponse } from '@/types' export interface PasskeyCredentialSummary { id: number @@ -104,11 +104,11 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record { +async function login(proof?: ActionCaptchaRequestProof): Promise { requirePasskeySupport() - const { data: begin } = await apiClient.post( - '/auth/passkey/login/begin' - ) + const { data: begin } = proof + ? await apiClient.post('/auth/passkey/login/begin', proof) + : await apiClient.post('/auth/passkey/login/begin') const credential = await navigator.credentials.get({ publicKey: requestOptionsFromJSON(begin.options.publicKey) }) diff --git a/frontend/src/api/tokenRefresh.ts b/frontend/src/api/tokenRefresh.ts new file mode 100644 index 0000000000..142dbba044 --- /dev/null +++ b/frontend/src/api/tokenRefresh.ts @@ -0,0 +1,241 @@ +import axios from 'axios' +import type { ApiResponse } from '@/types' +import { getAPIBaseURL } from './url' + +const AUTH_TOKEN_KEY = 'auth_token' +const AUTH_USER_KEY = 'auth_user' +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 + +export interface RefreshTokenResponse { + access_token: string + refresh_token: string + expires_in: number + token_type: string +} + +export interface RefreshAuthTokensOptions { + /** Access token attached to the request that received a 401 response. */ + failedAccessToken?: string | null +} + +interface AuthSnapshot { + accessToken: string | null + refreshToken: string + expiresAt: number + userID: number | null +} + +let inFlightRefresh: Promise | null = null + +function getStoredUserID(): number | null { + const rawUser = localStorage.getItem(AUTH_USER_KEY) + if (!rawUser) { + return null + } + + try { + const id = Number((JSON.parse(rawUser) as { id?: unknown }).id) + return Number.isFinite(id) && id > 0 ? id : null + } catch { + return null + } +} + +function readAuthSnapshot(): AuthSnapshot { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) + if (!refreshToken) { + throw new Error('No refresh token available') + } + + return { + accessToken: localStorage.getItem(AUTH_TOKEN_KEY), + refreshToken, + expiresAt: Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)), + userID: getStoredUserID() + } +} + +function readStoredTokenPair(snapshot: AuthSnapshot): RefreshTokenResponse | null { + const accessToken = localStorage.getItem(AUTH_TOKEN_KEY) + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) + const expiresAt = Number(localStorage.getItem(TOKEN_EXPIRES_AT_KEY)) + + if ( + !accessToken || + !refreshToken || + !Number.isFinite(expiresAt) || + expiresAt <= Date.now() || + getStoredUserID() !== snapshot.userID + ) { + return null + } + + return { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)), + token_type: 'Bearer' + } +} + +function readPeerRefreshResult( + snapshot: AuthSnapshot, + failedAccessToken?: string | null +): RefreshTokenResponse | null { + const storedPair = readStoredTokenPair(snapshot) + if (!storedPair) { + return null + } + + if (storedPair.refresh_token !== snapshot.refreshToken) { + return storedPair + } + + if ( + failedAccessToken && + snapshot.accessToken !== failedAccessToken && + storedPair.access_token === snapshot.accessToken + ) { + 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 +} + +async function waitForPeerRefresh( + snapshot: AuthSnapshot, + failedAccessToken?: string | null, + deadline = Date.now() + PEER_REFRESH_WAIT_MS +): Promise { + while (Date.now() < deadline) { + const peerResult = readPeerRefreshResult(snapshot, failedAccessToken) + if (peerResult) { + return peerResult + } + await new Promise((resolve) => window.setTimeout(resolve, PEER_REFRESH_POLL_MS)) + } + + return readPeerRefreshResult(snapshot, failedAccessToken) +} + +function persistTokenPair(tokens: RefreshTokenResponse): void { + localStorage.setItem(AUTH_TOKEN_KEY, tokens.access_token) + localStorage.setItem(TOKEN_EXPIRES_AT_KEY, String(Date.now() + tokens.expires_in * 1000)) + // The rotating refresh token is written last so other tabs can treat its change as a commit marker. + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token) +} + +async function requestTokenPair( + snapshot: AuthSnapshot, + failedAccessToken?: string | null, + mayHaveUncoordinatedPeer = false +): Promise { + // If this request loses a one-time-token race, the winning peer can legitimately take as long + // as our own HTTP timeout to publish its replacement token. Keep the recovery window tied to + // that timeout instead of an arbitrary short delay. + const peerRefreshDeadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS + PEER_REFRESH_GRACE_MS + + try { + const response = await axios.post>( + `${getAPIBaseURL()}/auth/refresh`, + { refresh_token: snapshot.refreshToken }, + { headers: { 'Content-Type': 'application/json' }, timeout: TOKEN_REFRESH_TIMEOUT_MS } + ) + const payload = response.data + if (payload.code !== 0 || !payload.data) { + throw new Error(payload.message || 'Token refresh failed') + } + + if ( + localStorage.getItem(REFRESH_TOKEN_KEY) !== snapshot.refreshToken || + getStoredUserID() !== snapshot.userID + ) { + const peerResult = readPeerRefreshResult(snapshot, failedAccessToken) + if (peerResult) { + return peerResult + } + throw new Error('Session changed during token refresh') + } + + persistTokenPair(payload.data) + return payload.data + } catch (error) { + // A peer tab may have rotated the one-time refresh token while this request was in flight. + // A 4xx response can arrive quickly while the winning peer's response is still in flight, so + // wait through the shared request deadline before treating the session as expired. Transient + // non-4xx failures retain the short reconciliation window. + const responseStatus = (error as { response?: { status?: unknown } }).response?.status + const isTokenRejection = + typeof responseStatus === 'number' && responseStatus >= 400 && responseStatus < 500 + const peerResult = await waitForPeerRefresh( + snapshot, + failedAccessToken, + isTokenRejection && mayHaveUncoordinatedPeer + ? peerRefreshDeadline + : Date.now() + PEER_REFRESH_WAIT_MS + ) + if (peerResult) { + return peerResult + } + throw error + } +} + +async function runRefresh(options: RefreshAuthTokensOptions): Promise { + const snapshot = readAuthSnapshot() + const refresh = async (mayHaveUncoordinatedPeer = false): Promise => { + const peerResult = readPeerRefreshResult(snapshot, options.failedAccessToken) + if (peerResult) { + return peerResult + } + return requestTokenPair(snapshot, options.failedAccessToken, mayHaveUncoordinatedPeer) + } + + if (typeof navigator !== 'undefined' && navigator.locks) { + return navigator.locks.request(TOKEN_REFRESH_LOCK_NAME, () => refresh(false)) + } + + return refresh(true) +} + +/** + * Refresh and persist the browser session. + * + * Calls in the same document share one promise. Web Locks serialize refreshes across tabs, while + * the token snapshot check adopts a peer's newly rotated token instead of logging the user out. + */ +export function refreshAuthTokens( + options: RefreshAuthTokensOptions = {} +): Promise { + if (inFlightRefresh) { + return inFlightRefresh + } + + const pending = runRefresh(options) + inFlightRefresh = pending + const clearPending = (): void => { + if (inFlightRefresh === pending) { + inFlightRefresh = null + } + } + void pending.then(clearPending, clearPending) + return pending +} diff --git a/frontend/src/components/AliyunCaptchaWidget.vue b/frontend/src/components/AliyunCaptchaWidget.vue new file mode 100644 index 0000000000..6eec329a66 --- /dev/null +++ b/frontend/src/components/AliyunCaptchaWidget.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/frontend/src/components/CaptchaChallenge.vue b/frontend/src/components/CaptchaChallenge.vue new file mode 100644 index 0000000000..f9426c6d49 --- /dev/null +++ b/frontend/src/components/CaptchaChallenge.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/components/TencentCaptchaGate.vue b/frontend/src/components/TencentCaptchaGate.vue new file mode 100644 index 0000000000..610ba8a94c --- /dev/null +++ b/frontend/src/components/TencentCaptchaGate.vue @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts b/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts new file mode 100644 index 0000000000..6158b856f8 --- /dev/null +++ b/frontend/src/components/__tests__/AliyunCaptchaWidget.spec.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import AliyunCaptchaWidget from '../AliyunCaptchaWidget.vue' + +interface CapturedInitOptions { + SceneId: string + prefix: string + mode: string + element: string + button: string + captchaVerifyCallback: (param: string) => { captchaResult: boolean } + language?: string +} + +const i18nStub = { + install(app: { config: { globalProperties: Record } }) { + app.config.globalProperties.$t = (key: string) => key + } +} + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ locale: { value: 'zh-CN' }, t: (key: string) => key }) +})) + +describe('AliyunCaptchaWidget', () => { + let initOptions: CapturedInitOptions | null + + beforeEach(() => { + initOptions = null + window.initAliyunCaptcha = vi.fn((options: CapturedInitOptions) => { + initOptions = options + }) as unknown as typeof window.initAliyunCaptcha + }) + + afterEach(() => { + vi.useRealTimers() + delete window.initAliyunCaptcha + delete window.AliyunCaptchaConfig + document.getElementById('aliyunCaptcha-window-popup')?.remove() + document.getElementById('aliyunCaptcha-mask')?.remove() + }) + + function mountWidget() { + return mount(AliyunCaptchaWidget, { + props: { sceneId: 'scene-1', prefix: 'prefix-1', region: 'cn' as const }, + attachTo: document.body, + global: { plugins: [i18nStub] } + }) + } + + function createVisiblePopup(): HTMLElement { + const popup = document.createElement('div') + popup.id = 'aliyunCaptcha-window-popup' + popup.style.display = 'block' + document.body.appendChild(popup) + return popup + } + + it('渲染可见验证按钮并以 popup 模式初始化,全局配置就位', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const button = wrapper.get('button') + expect(button.text()).toContain('auth.captchaClickToVerify') + expect(window.AliyunCaptchaConfig).toEqual({ region: 'cn', prefix: 'prefix-1' }) + expect(initOptions).not.toBeNull() + expect(initOptions!.mode).toBe('popup') + expect(initOptions!.SceneId).toBe('scene-1') + expect(initOptions!.language).toBe('cn') + + wrapper.unmount() + }) + + it('用户点击按钮进入验证中,验证完成后 emit verify 并置已通过', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + expect(wrapper.get('button').text()).toContain('auth.captchaVerifying') + + const result = initOptions!.captchaVerifyCallback('captcha-param-1') + expect(result).toEqual({ captchaResult: true }) + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('verify')).toEqual([['captcha-param-1']]) + expect(wrapper.get('button').text()).toContain('auth.captchaVerified') + expect(wrapper.get('button').attributes('disabled')).toBeDefined() + + wrapper.unmount() + }) + + it('verify() 在已预验证时直接复用缓存的 captchaVerifyParam', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + initOptions!.captchaVerifyCallback('captcha-param-2') + + const vm = wrapper.vm as unknown as { verify: () => Promise } + await expect(vm.verify()).resolves.toBe('captcha-param-2') + + wrapper.unmount() + }) + + it('verify() 在未预验证时触发弹窗流程并等待结果', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + const pending = vm.verify() + await Promise.resolve() + await Promise.resolve() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').text()).toContain('auth.captchaVerifying') + + initOptions!.captchaVerifyCallback('captcha-param-3') + await expect(pending).resolves.toBe('captcha-param-3') + + wrapper.unmount() + }) + + it('弹窗未出现前会按 tick 重试触发按钮(SDK 异步绑定兜底)', async () => { + vi.useFakeTimers() + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + void vm.verify() + await Promise.resolve() + await Promise.resolve() + + const button = wrapper.get('button').element as HTMLButtonElement + const clickSpy = vi.fn() + button.addEventListener('click', clickSpy) + + await vi.advanceTimersByTimeAsync(1000) + expect(clickSpy.mock.calls.length).toBeGreaterThanOrEqual(3) + + button.removeEventListener('click', clickSpy) + wrapper.unmount() + }) + + it('弹窗出现后被用户关闭时 resolve null 并回到未验证态', async () => { + vi.useFakeTimers() + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + const vm = wrapper.vm as unknown as { verify: () => Promise } + const pending = vm.verify() + await Promise.resolve() + await Promise.resolve() + + const popup = createVisiblePopup() + await vi.advanceTimersByTimeAsync(400) + popup.remove() + await vi.advanceTimersByTimeAsync(400) + + await expect(pending).resolves.toBeNull() + expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify') + + wrapper.unmount() + }) + + it('reset() 清空缓存并取消进行中的验证', async () => { + const wrapper = mountWidget() + await Promise.resolve() + await Promise.resolve() + + await wrapper.get('button').trigger('click') + initOptions!.captchaVerifyCallback('captcha-param-4') + + const vm = wrapper.vm as unknown as { + verify: () => Promise + reset: () => void + } + vm.reset() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').text()).toContain('auth.captchaClickToVerify') + + // reset 后缓存失效,verify() 重新走弹窗流程 + const pending = vm.verify() + await Promise.resolve() + initOptions!.captchaVerifyCallback('captcha-param-5') + await expect(pending).resolves.toBe('captcha-param-5') + + wrapper.unmount() + }) +}) + +declare global { + interface Window { + initAliyunCaptcha?: (options: unknown) => void + AliyunCaptchaConfig?: { region: string; prefix: string } + } +} diff --git a/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts new file mode 100644 index 0000000000..d6ffc3bd43 --- /dev/null +++ b/frontend/src/components/__tests__/TencentCaptchaGate.spec.ts @@ -0,0 +1,351 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue' +import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha' + +const locale = { value: 'zh' } + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ locale }) +})) + +type CaptchaResult = { + ret: number + ticket?: string | null + randstr?: string | null + errorCode?: number +} + +describe('TencentCaptchaGate', () => { + beforeEach(() => { + locale.value = 'zh' + delete window.TencentCaptcha + delete window.TCaptchaGlobal + document.head + .querySelectorAll('script[src*="TJCaptcha.js"], script[src*="TJNCaptcha-global.js"]') + .forEach((node) => node.remove()) + resetTencentCaptchaLoaderForTest() + }) + + it('does not render a visible verification button', () => { + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + expect(wrapper.find('button').exists()).toBe(false) + }) + + it('resolves proof after Tencent SDK success', async () => { + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' }) + + await expect(verification).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + }) + + it('resolves null when the user closes the popup', async () => { + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 2, ticket: null }) + + await expect(verification).resolves.toBeNull() + }) + + it('rejects SDK load failures and disaster-recovery tickets', async () => { + const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + const loadVerification = failedLoad.vm.verify() + await flushPromises() + const script = document.head.querySelector('script[src*="TJCaptcha.js"]') + expect(script).not.toBeNull() + script?.dispatchEvent(new Event('error')) + await expect(loadVerification).rejects.toThrow('Failed to load Tencent Captcha SDK') + + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = vi.fn() + destroy = vi.fn() + } + const failedResult = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + const resultVerification = failedResult.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'trerror_1001_123456789', randstr: '@fallback', errorCode: 1001 }) + + await expect(resultVerification).rejects.toThrow('Tencent Captcha verification failed') + }) + + it('reuses one pending promise for concurrent verify calls', async () => { + const show = vi.fn() + let callback: ((result: CaptchaResult) => void) | undefined + window.TencentCaptcha = class { + constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) { + callback = resultCallback + } + show = show + destroy = vi.fn() + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const first = wrapper.vm.verify() + const second = wrapper.vm.verify() + await flushPromises() + callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' }) + + await expect(first).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + await expect(second).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' }) + expect(show).toHaveBeenCalledOnce() + }) + + // 国际站新版 SDK 会先把 Robot checkbox 渲染到首参容器,点击后才弹出挑战。 + // 容器必须位于当前表单中;传 document.body 会让 checkbox 落在页面末尾并超出视口。 + it('passes a visible in-form container first for the international site', async () => { + const args: unknown[][] = [] + let callback: ((result: CaptchaResult) => void) | undefined + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor(...received: unknown[]) { + args.push(received) + callback = received[2] as (result: CaptchaResult) => void + } + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + const verification = wrapper.vm.verify() + await flushPromises() + + const container = wrapper.get('[data-testid="tencent-captcha-international-container"]') + expect(args).toHaveLength(1) + expect(args[0][0]).toBe(container.element) + expect(args[0][0]).not.toBe(document.body) + await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]')) + expect(args[0][1]).toBe('123456789') + expect(typeof args[0][2]).toBe('function') + expect(args[0][3]).toMatchObject({ enableAutoCheck: false, type: 'popup' }) + + callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' }) + await expect(verification).resolves.toEqual({ + ticket: 'ticket-value', + randstr: 'rand-value' + }) + expect(container.classes()).not.toContain('scale-[0.01]') + }) + + it('preloads and displays the international checkbox on mount', async () => { + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor() {} + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + const container = wrapper.get('[data-testid="tencent-captcha-international-container"]') + await flushPromises() + await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]')) + }) + + it('reuses a preloaded proof when the SDK reports success', async () => { + let callback: ((result: CaptchaResult) => void) | undefined + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor(...received: unknown[]) { + callback = received[2] as (result: CaptchaResult) => void + } + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + await flushPromises() + callback?.({ ret: 0, ticket: 'preloaded-ticket', randstr: 'preloaded-rand' }) + await flushPromises() + + await expect(wrapper.vm.verify()).resolves.toEqual({ + ticket: 'preloaded-ticket', + randstr: 'preloaded-rand' + }) + expect(wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()).not.toContain( + 'scale-[0.01]' + ) + }) + + it('refreshes a preloaded proof before the provider ticket expires', async () => { + vi.useFakeTimers() + try { + let callback: ((result: CaptchaResult) => void) | undefined + let constructorCount = 0 + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor(...received: unknown[]) { + constructorCount += 1 + callback = received[2] as (result: CaptchaResult) => void + } + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + await flushPromises() + callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' }) + await flushPromises() + vi.advanceTimersByTime(4 * 60 * 1000) + + const verification = wrapper.vm.verify() + await flushPromises() + expect(constructorCount).toBe(2) + + callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' }) + await expect(verification).resolves.toEqual({ + ticket: 'fresh-ticket', + randstr: 'fresh-rand' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the three-argument form for the Chinese mainland site', async () => { + const args: unknown[][] = [] + window.TencentCaptcha = class { + constructor(...received: unknown[]) { + args.push(received) + } + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + void wrapper.vm.verify() + await flushPromises() + + expect(args[0][0]).toBe('123456789') + expect(typeof args[0][1]).toBe('function') + }) + + it('loads the international SDK script for the international site', async () => { + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + void wrapper.vm.verify() + await flushPromises() + + const script = document.head.querySelector( + 'script[src*="TJNCaptcha-global.js"]' + ) + expect(script?.src).toBe('https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js') + }) + + // 页面上已有的全局构造函数可能来自另一个站点(例如开发期 HMR 重置了模块状态)。 + // 两个站点签名不兼容,错配会抛错,因此站点不一致时必须重新加载对应脚本而不是复用。 + it('does not reuse a mainland global for the international site', async () => { + window.TencentCaptcha = class { + constructor() {} + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + void wrapper.vm.verify() + await flushPromises() + + expect( + document.head.querySelector('script[src*="TJNCaptcha-global.js"]') + ).not.toBeNull() + }) + + it('does not inject a second SDK when the region changes in one page', async () => { + const constructor = class { + constructor() {} + show = vi.fn() + destroy = vi.fn() + } as unknown as typeof window.TencentCaptcha + + const firstLoad = loadTencentCaptcha('cn') + const firstScript = document.head.querySelector('script[src*="TJCaptcha.js"]') + expect(firstScript).not.toBeNull() + window.TencentCaptcha = constructor + firstScript?.dispatchEvent(new Event('load')) + await expect(firstLoad).resolves.toBe(constructor) + + await expect(loadTencentCaptcha('intl')).rejects.toThrow( + 'Tencent Captcha region changed; reload the page to apply it' + ) + expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull() + }) + + it('settles a pending verification when reset', async () => { + const destroy = vi.fn() + window.TencentCaptcha = class { + constructor(_appId: string, _callback: (result: CaptchaResult) => void) {} + show = vi.fn() + destroy = destroy + } + const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } }) + + const verification = wrapper.vm.verify() + await flushPromises() + wrapper.vm.reset() + + await expect(verification).resolves.toBeNull() + expect(destroy).toHaveBeenCalledOnce() + }) + + it('reinitializes the international checkbox after reset', async () => { + const destroy = vi.fn() + let constructorCount = 0 + window.TCaptchaGlobal = true + window.TencentCaptcha = class { + constructor() { + constructorCount += 1 + } + show = vi.fn() + destroy = destroy + } as unknown as typeof window.TencentCaptcha + const wrapper = mount(TencentCaptchaGate, { + props: { appId: '123456789', region: 'intl' } + }) + + await flushPromises() + expect(constructorCount).toBe(1) + wrapper.vm.reset() + await flushPromises() + + expect(destroy).toHaveBeenCalledOnce() + expect(constructorCount).toBe(2) + expect( + wrapper.get('[data-testid="tencent-captcha-international-container"]').classes() + ).not.toContain('scale-[0.01]') + }) +}) diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 9c1d25f635..4130ecd8ec 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -14,15 +14,19 @@ @@ -144,7 +142,7 @@ refresh button is rendered via the pre-actions slot so the user sees a single row of related buttons instead of two stacked rows. --> - + @@ -352,82 +354,72 @@
- {{ grokEntitlementLabel || t('admin.accounts.forbidden') }} + {{ usageInfo?.grok_entitlement_status || t('admin.accounts.forbidden') }}
-
- - {{ grokEntitlementLabel }} - -
-
-
- - {{ formatWindowRequests(grokLocalUsage) }} req - - - {{ formatWindowTokens(grokLocalUsage) }} - - - A ${{ formatWindowCost(grokLocalUsage) }} - + + + +
+ {{ usageErrorLabel }}
- - - -
{{ t('admin.accounts.usageWindow.grokRetryAfter', { time: grokRetryAfterLabel }) }}
-
- {{ grokQuotaUnknownLabel }} -
-
- {{ usageErrorLabel }} -
-
- {{ grokQuotaStatusLine }} -
- + +
+
+
-
+
-
-
@@ -625,11 +617,10 @@ import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' -import type { GrokQuotaProbeResult } from '@/api/admin/grok' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { enqueueUsageRequest } from '@/utils/usageLoadQueue' -import { formatCompactNumber, formatRelativeTime } from '@/utils/format' +import { formatCompactNumber } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue' @@ -639,6 +630,8 @@ import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue' // Module-level cache shared across all AccountUsageCell instances const _usageCache = new Map() const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes +// How long a quota-reset response may suppress the row-patch usage refetch. +const SUPPRESS_USAGE_REFRESH_WINDOW_MS = 5 * 1000 const props = withDefaults( defineProps<{ @@ -646,14 +639,27 @@ const props = withDefaults( todayStats?: WindowStats | null todayStatsLoading?: boolean manualRefreshToken?: number + batchedUsage?: AccountUsageInfo | null + batchedUsageError?: string | null + batchedUsageLoading?: boolean + requestBatchedUsage?: ((account: Account, options?: { force?: boolean }) => void) | null }>(), { todayStats: null, todayStatsLoading: false, - manualRefreshToken: 0 + manualRefreshToken: 0, + batchedUsage: null, + batchedUsageError: null, + batchedUsageLoading: false, + requestBatchedUsage: null } ) +const emit = defineEmits<{ + 'account-updated': [account: Account] + 'usage-loaded': [usage: AccountUsageInfo] +}>() + const { t } = useI18n() const desktopViewportQuery = '(min-width: 768px)' @@ -664,6 +670,10 @@ const loading = ref(false) const activeQueryLoading = ref(false) const error = ref(null) const usageInfo = ref(null) +watch(usageInfo, (usage) => { + if (usage) emit('usage-loaded', usage) +}) +const suppressOpenAIUsageRefreshUntil = ref(0) const rootRef = ref(null) const isDesktopViewport = ref( typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches @@ -702,6 +712,8 @@ const shouldFetchUsage = computed(() => { return false }) +const isBatchManaged = computed(() => typeof props.requestBatchedUsage === 'function') + const showGeminiTodayStats = computed(() => { return props.account.platform === 'gemini' && props.account.type === 'service_account' }) @@ -1061,17 +1073,6 @@ interface GrokQuotaBarInfo { resetsAt: string | null } -const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => { - if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null - const remaining = Math.min(quota.limit, Math.max(0, quota.remaining)) - return { - utilization: (remaining / quota.limit) * 100, - resetsAt: quota.reset_at || null - } -} - -const grokRequestQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_request_quota)) -const grokTokenQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_token_quota)) const grokBilling = computed(() => usageInfo.value?.grok_billing || null) const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => { const billing = grokBilling.value @@ -1083,6 +1084,63 @@ const grokWeeklyBillingBar = computed((): GrokQuotaBarInfo | null => { resetsAt: billing.period_end || null } }) +// Monthly used/limit % from billing probe (used_percent or derived from cents). +const grokMonthlyBillingBar = computed((): GrokQuotaBarInfo | null => { + const billing = grokBilling.value + if (!billing) return null + let utilization: number | null = null + if (billing.used_percent != null && Number.isFinite(billing.used_percent)) { + utilization = billing.used_percent + } else if ( + billing.monthly_limit_cents != null && + billing.monthly_limit_cents > 0 && + billing.used_cents != null + ) { + utilization = (billing.used_cents / billing.monthly_limit_cents) * 100 + } + if (utilization == null) return null + // Avoid duplicating the weekly bar when period_type is weekly-only without monthly. + if (billing.period_type?.toLowerCase() === 'weekly' && billing.monthly_limit_cents == null) { + return null + } + return { + utilization: Math.min(100, Math.max(0, utilization)), + resetsAt: billing.billing_period_end || billing.period_end || null + } +}) +const formatGrokMoney = (value?: number | null) => { + if (value == null || Number.isNaN(value)) return '0' + if (value >= 1000) return formatCompactNumber(value) + if (value >= 100) return value.toFixed(0) + if (value >= 10) return value.toFixed(1) + return value.toFixed(2) +} +// Prepaid money line for paid Grok: show when prepaid_balance is present. +// Monthly used/limit numbers are optional context; primary progress is the 30d bar. +const grokPrepaidMoneyLine = computed(() => { + const billing = grokBilling.value + if (!billing) return null + const prepaid = billing.prepaid_balance + // "只针对预付": only render when prepaid field exists (including $0.00). + if (prepaid == null || !Number.isFinite(prepaid)) return null + const used = + billing.monthly_used != null + ? billing.monthly_used + : billing.used_cents != null + ? billing.used_cents / 100 + : 0 + const limit = + billing.monthly_limit != null + ? billing.monthly_limit + : billing.monthly_limit_cents != null + ? billing.monthly_limit_cents / 100 + : 0 + return { + prepaid: formatGrokMoney(prepaid), + used: formatGrokMoney(used), + limit: formatGrokMoney(limit) + } +}) const grokPlanLabelIsFree = (value: string) => value.includes('free') || value.includes('basic') const grokPlanLabelIsPaid = (value: string) => { return value !== '' && !grokPlanLabelIsFree(value) && !value.includes('unknown') @@ -1108,14 +1166,6 @@ const grokIsFree = computed(() => { return billing != null }) const grokFreeQuotaUsage = computed(() => usageInfo.value?.grok_local_usage_24h || null) -const grokLocalUsage = computed(() => { - if (grokIsFree.value) return grokFreeQuotaUsage.value - return props.todayStats || - usageInfo.value?.grok_local_usage || - usageInfo.value?.grok_local_usage_7d || - usageInfo.value?.grok_local_usage_monthly || - null -}) const grokFreeTokenBar = computed(() => { if (!grokIsFree.value || !grokFreeQuotaUsage.value) return null const limit = usageInfo.value?.grok_free_token_limit @@ -1125,7 +1175,12 @@ const grokFreeTokenBar = computed(() => { }) const grokQuotaUnknown = computed(() => { if (props.account.platform !== 'grok') return false - if (grokBilling.value || grokFreeTokenBar.value || grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false + if (grokIsFree.value) { + return !grokFreeTokenBar.value + } + if (grokWeeklyBillingBar.value || grokMonthlyBillingBar.value || grokPrepaidMoneyLine.value) { + return false + } return usageInfo.value?.grok_quota_snapshot_state !== 'observed' }) const grokQuotaUnknownLabel = computed(() => { @@ -1133,33 +1188,6 @@ const grokQuotaUnknownLabel = computed(() => { ? t('admin.accounts.usageWindow.grokNoHeaders') : t('admin.accounts.usageWindow.grokUnknown') }) -const grokQuotaStatusLine = computed(() => { - if (props.account.platform !== 'grok') return null - const parts: string[] = [] - const status = usageInfo.value?.grok_last_status_code - if (status) { - parts.push(t('admin.accounts.usageWindow.grokLastStatus', { status })) - } - if (usageInfo.value?.grok_last_quota_probe_at) { - parts.push( - t('admin.accounts.usageWindow.grokLastProbe', { - time: formatRelativeTime(usageInfo.value.grok_last_quota_probe_at) - }) - ) - } - if (usageInfo.value?.grok_last_headers_seen_at) { - parts.push( - t('admin.accounts.usageWindow.grokLastHeadersSeen', { - time: formatRelativeTime(usageInfo.value.grok_last_headers_seen_at) - }) - ) - } - return parts.length > 0 ? parts.join(' | ') : null -}) -const grokEntitlementLabel = computed(() => { - const status = (usageInfo.value?.grok_entitlement_status || '').trim() - return status || null -}) const grokRetryAfterLabel = computed(() => { const seconds = usageInfo.value?.grok_retry_after_seconds if (seconds == null || seconds <= 0) return null @@ -1168,11 +1196,6 @@ const grokRetryAfterLabel = computed(() => { return `${minutes}m` }) -const formatWindowRequests = (stats: WindowStats) => formatCompactNumber(stats.requests, { allowBillions: false }) -const formatWindowTokens = (stats: WindowStats) => formatCompactNumber(stats.tokens) -const formatWindowCost = (stats: WindowStats) => stats.cost.toFixed(2) -const formatWindowUserCost = (stats: WindowStats) => (stats.user_cost ?? 0).toFixed(2) - // 账户类型显示标签 const antigravityTierLabel = computed(() => { switch (antigravityTier.value) { @@ -1262,8 +1285,24 @@ const isAnthropicOAuthOrSetupToken = computed(() => { return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token') }) +const requestParentBatchUsage = (options?: { force?: boolean }) => { + if (!isBatchManaged.value || !shouldFetchUsage.value) return + props.requestBatchedUsage?.(props.account, options) +} + +const syncManagedUsageState = () => { + if (!isBatchManaged.value) return + usageInfo.value = props.batchedUsage ?? null + error.value = props.batchedUsageError ?? null + loading.value = props.batchedUsageLoading === true +} + const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?: boolean }) => { if (!shouldFetchUsage.value) return + if (isBatchManaged.value) { + requestParentBatchUsage({ force: options?.bypassCache === true }) + return + } // Check cache if (!options?.bypassCache) { @@ -1279,9 +1318,9 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache? error.value = null try { - const fetchFn = () => options?.source - ? adminAPI.accounts.getUsage(props.account.id, options.source) - : adminAPI.accounts.getUsage(props.account.id) + const fetchFn = () => options?.source + ? adminAPI.accounts.getUsage(props.account.id, options.source, options.bypassCache === true) + : adminAPI.accounts.getUsage(props.account.id) const result = await enqueueUsageRequest(props.account, fetchFn) if (!unmounted.value) { usageInfo.value = result @@ -1358,54 +1397,10 @@ const loadActiveUsage = async () => { } } -const handleGrokProbed = (result: GrokQuotaProbeResult) => { - const current = usageInfo.value - if (!current) return - const snapshot = result.snapshot - const statusCode = snapshot?.status_code ?? result.status_code - const hasActiveProbeSnapshot = snapshot != null && ( - result.source === 'active_probe' || - result.source === 'hybrid_probe' || - snapshot.observation_source === 'active_probe' - ) - const probeSucceeded = hasActiveProbeSnapshot && - statusCode != null && statusCode >= 200 && statusCode < 300 - const snapshotEntitlement = snapshot?.entitlement_status?.trim() - const currentEntitlement = current.grok_entitlement_status?.trim() - const entitlementStatus = snapshotEntitlement || ( - probeSucceeded && currentEntitlement?.toLowerCase() === 'forbidden' - ? undefined - : current.grok_entitlement_status - ) - const merged: AccountUsageInfo = { - ...current, - grok_billing: result.billing ?? current.grok_billing, - grok_local_usage_24h: result.local_usage_24h ?? current.grok_local_usage_24h, - grok_local_usage_7d: result.local_usage_7d ?? current.grok_local_usage_7d, - grok_local_usage_monthly: result.local_usage_monthly ?? current.grok_local_usage_monthly, - grok_request_quota: snapshot?.requests ?? current.grok_request_quota, - grok_token_quota: snapshot?.tokens ?? current.grok_token_quota, - grok_retry_after_seconds: snapshot?.retry_after_seconds ?? current.grok_retry_after_seconds, - grok_entitlement_status: entitlementStatus, - grok_quota_snapshot_state: result.billing - ? 'billing_observed' - : snapshot?.headers_observed - ? 'observed' - : current.grok_quota_snapshot_state, - grok_last_quota_probe_at: result.billing?.fetched_at ?? snapshot?.last_probe_at ?? current.grok_last_quota_probe_at, - grok_last_headers_seen_at: snapshot?.last_headers_seen_at ?? current.grok_last_headers_seen_at, - grok_last_status_code: result.status_code ?? snapshot?.status_code ?? current.grok_last_status_code, - is_forbidden: probeSucceeded ? false : current.is_forbidden, - forbidden_reason: probeSucceeded ? undefined : current.forbidden_reason, - forbidden_type: probeSucceeded ? undefined : current.forbidden_type, - validation_url: probeSucceeded ? undefined : current.validation_url, - needs_verify: probeSucceeded ? false : current.needs_verify, - is_banned: probeSucceeded ? false : current.is_banned, - error: result.billing || snapshot ? undefined : current.error, - error_code: result.billing || snapshot ? undefined : current.error_code - } - usageInfo.value = merged - _usageCache.set(props.account.id, { data: merged, ts: Date.now() }) +// The probe persists upstream quota state; refresh this cell so its compact +// bars and entitlement status reflect the newly observed snapshot. +const handleGrokProbed = async () => { + await loadUsage({ source: 'active', bypassCache: true }) } // ===== API Key quota progress bars ===== @@ -1473,6 +1468,15 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { return makeQuotaBar(props.account.quota_used ?? 0, limit) }) +const handleQuotaResetAccountUpdated = (account: Account) => { + // The reset response already carries authoritative quota and account data. + // Avoid turning the parent patch into a second automatic /usage request. + // The suppression is time-boxed so an unhandled emit (parent that ignores + // account-updated) cannot latch it and swallow a later, unrelated refresh. + suppressOpenAIUsageRefreshUntil.value = Date.now() + SUPPRESS_USAGE_REFRESH_WINDOW_MS + emit('account-updated', account) +} + // ===== Key account today stats formatters ===== const formatKeyRequests = computed(() => { @@ -1509,14 +1513,61 @@ onMounted(() => { } } + if (isBatchManaged.value) { + syncManagedUsageState() + requestParentBatchUsage() + return + } + if (!shouldAutoLoadUsageOnMount.value) return const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined requestAutoLoad(source) }) +watch( + () => [props.batchedUsage, props.batchedUsageError, props.batchedUsageLoading, isBatchManaged.value] as const, + () => { + syncManagedUsageState() + }, + { immediate: true, deep: true } +) + +watch(isBatchManaged, (managed, wasManaged) => { + if (managed && !wasManaged) { + syncManagedUsageState() + requestParentBatchUsage() + } +}) + +watch( + () => [props.account.id, props.account.platform, props.account.type, isBatchManaged.value] as const, + ([accountID, platform, accountType, managed], [previousAccountID, previousPlatform, previousAccountType]) => { + if ( + accountID === previousAccountID && + platform === previousPlatform && + accountType === previousAccountType + ) { + return + } + if (!managed || !shouldFetchUsage.value) return + syncManagedUsageState() + requestParentBatchUsage() + }, + { flush: 'post' } +) + watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return + if (Date.now() < suppressOpenAIUsageRefreshUntil.value) { + suppressOpenAIUsageRefreshUntil.value = 0 + return + } + + if (isBatchManaged.value) { + requestParentBatchUsage({ force: true }) + return + } _usageCache.delete(props.account.id) requestAutoLoad() @@ -1528,6 +1579,11 @@ watch( if (nextToken === prevToken) return if (!shouldFetchUsage.value) return + if (isBatchManaged.value) { + requestParentBatchUsage({ force: true }) + return + } + const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined _usageCache.delete(props.account.id) loadUsage({ source, bypassCache: true }).catch((e) => { diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index a4268fb926..ad9998695d 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -82,6 +82,57 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+ +
+
+
@@ -699,6 +750,14 @@ aria-labelledby="bulk-edit-rate-multiplier-label" />

{{ t('admin.accounts.billingRateMultiplierHint') }}

+

+ + {{ t('admin.accounts.bulkEdit.rateSyncWarning') }} +

@@ -854,8 +913,8 @@ - -
+ +
+ +
+
+ +

+ {{ t('admin.accounts.upstreamBilling.autoProbeHint') }} +

+
+ +
@@ -1143,8 +1157,8 @@

{{ apiKeyHint }}

+
@@ -2781,6 +2795,37 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
@@ -3269,8 +3316,8 @@
@@ -3778,6 +3825,8 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record) => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) +// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) const openAILongContextBillingTouched = ref(false) const openAICompactMode = ref('auto') @@ -4231,6 +4280,7 @@ watch( } if (newPlatform !== 'openai') { openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF @@ -4574,7 +4624,6 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { try { const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) if ( - payload.platform === 'openai' && payload.type === 'apikey' && payload.upstream_billing_probe_enabled === true ) { @@ -4656,6 +4705,7 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAILongContextBillingTouched.value = false openAICompactMode.value = 'auto' @@ -4740,6 +4790,12 @@ const buildOpenAIExtra = (base?: Record): Record { ...form, group_ids: form.group_ids, extra, - upstream_billing_probe_enabled: - form.platform === 'openai' ? upstreamBillingAutoProbeEnabled.value : undefined, + upstream_billing_probe_enabled: upstreamBillingAutoProbeEnabled.value, auto_pause_on_expired: autoPauseOnExpired.value }) } @@ -5253,6 +5308,9 @@ const createAccountAndFinish = async ( rate_multiplier: form.rate_multiplier, group_ids: form.group_ids, expires_at: form.expires_at, + // 上游倍率探测对全部 API-key 平台开放(antigravity upstream 走本 helper); + // 非 apikey 类型(bedrock/oauth)不传,后端不动作。 + upstream_billing_probe_enabled: type === 'apikey' ? upstreamBillingAutoProbeEnabled.value : undefined, auto_pause_on_expired: autoPauseOnExpired.value }) } @@ -5420,6 +5478,116 @@ const handleGrokImportSSO = async (ssoInput: string) => { } } +/** + * Grok password login: each line is email----password. + * Password is only used for the authorize API call; buildCredentials never stores it. + */ +const handleGrokAuthorizePassword = async (emailPasswordInput: string) => { + if (!emailPasswordInput.trim()) return + if (!validateGrokOAuthUpstreamConfig()) return + + const lines = emailPasswordInput + .split('\n') + // Keep the password portion byte-for-byte; trim is only for determining + // whether this textarea line is blank. + .filter((line) => line.trim() && line.includes('----')) + + if (lines.length === 0) { + grokOAuth.error.value = t( + 'admin.accounts.oauth.grok.pleaseEnterPassword', + 'Please enter email----password (one per line)' + ) + return + } + + grokOAuth.loading.value = true + grokOAuth.error.value = '' + + let successCount = 0 + let failedCount = 0 + const errors: string[] = [] + + try { + for (let i = 0; i < lines.length; i++) { + try { + const tokenInfo = await grokOAuth.authorizePassword(lines[i], form.proxy_id) + if (!tokenInfo) { + failedCount++ + errors.push(`#${i + 1}: ${grokOAuth.error.value || 'Authorization failed'}`) + grokOAuth.error.value = '' + continue + } + + const credentials = grokOAuth.buildCredentials(tokenInfo) + applyGrokOAuthUpstreamConfig(credentials) + const extra = grokOAuth.buildExtraInfo(tokenInfo) + const accountName = + lines.length > 1 + ? `${form.name || tokenInfo.email || 'Grok OAuth Account'} #${i + 1}` + : form.name || tokenInfo.email || 'Grok OAuth Account' + + const modelMapping = buildModelMappingObject( + modelRestrictionMode.value, + allowedModels.value, + modelMappings.value + ) + if (modelMapping) { + credentials.model_mapping = modelMapping + } + if (!applyTempUnschedConfig(credentials)) { + return + } + + await adminAPI.accounts.create({ + name: accountName, + notes: form.notes, + platform: 'grok', + type: 'oauth', + credentials, + extra, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + load_factor: form.load_factor ?? undefined, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value + }) + successCount++ + } catch (error: any) { + failedCount++ + const errMsg = error.response?.data?.detail || error.message || 'Unknown error' + errors.push(`#${i + 1}: ${errMsg}`) + } + } + + if (successCount > 0 && failedCount === 0) { + appStore.showSuccess( + lines.length > 1 + ? t('admin.accounts.oauth.batchSuccess', { count: successCount }) + : t('admin.accounts.accountCreated') + ) + emit('created') + handleClose() + } else if (successCount > 0) { + appStore.showWarning( + t('admin.accounts.oauth.batchPartialSuccess', { + success: successCount, + failed: failedCount + }) + ) + grokOAuth.error.value = errors.join('\n') + emit('created') + } else { + grokOAuth.error.value = errors.join('\n') + appStore.showError(t('admin.accounts.oauth.batchFailed')) + } + } finally { + grokOAuth.loading.value = false + } +} + // OpenAI OAuth 授权码兑换 const handleOpenAIExchange = async (authCode: string) => { const oauthClient = openaiOAuth diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index b15259ef3e..3bff385cd5 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1375,6 +1375,40 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.accountSchedulingThresholdOverrideHint') }} +

+
+ +
+
+ + +

{{ t('admin.accounts.accountSchedulingThresholdOverrideDisabledHint') }}

+
+
+
- -

{{ t('admin.accounts.billingRateMultiplierHint') }}

+ +

+ {{ + t( + upstreamBillingRateSyncEnabled + ? 'admin.accounts.upstreamBilling.syncRateManagedHint' + : 'admin.accounts.billingRateMultiplierHint' + ) + }} +

+
+
+

+ {{ t('admin.accounts.upstreamBilling.syncRate') }} +

+

+ {{ t('admin.accounts.upstreamBilling.syncRateHint') }} +

+
+ +
@@ -1481,6 +1550,37 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
@@ -1629,9 +1729,10 @@

@@ -2791,6 +2892,7 @@ const autoPause7dThreshold = ref(null) const autoPause5hDisabled = ref(false) const autoPause7dDisabled = ref(false) const upstreamBillingAutoProbeEnabled = ref(false) +const upstreamBillingRateSyncEnabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const antigravityProjectId = ref('') @@ -2799,6 +2901,12 @@ const antigravityWhitelistModels = ref([]) const antigravityModelMappings = ref([]) const isSyncingAntigravityUpstream = ref(false) const tempUnschedEnabled = ref(false) +const accountSchedulingThresholdOverrideEnabled = ref(false) +const accountSchedulingThresholdOverrideValue = ref(100) +const ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY = 'account_scheduling_threshold' +const supportsAccountSchedulingThresholdOverride = computed(() => + supportsAccountSchedulingThresholdOverridePlatform(props.account?.platform) +) const tempUnschedRules = ref([]) const getModelMappingKey = createStableObjectKeyResolver('edit-model-mapping') const getOpenAICompactModelMappingKey = createStableObjectKeyResolver('edit-openai-compact-model-mapping') @@ -2841,6 +2949,8 @@ const customBaseUrl = ref('') // OpenAI 自动透传开关(OAuth/API Key) const openaiPassthroughEnabled = ref(false) +// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) // OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别 const editPlanType = ref('') @@ -3158,6 +3268,20 @@ const form = reactive({ expires_at: null as number | null }) +const handleUpstreamBillingRateSyncChange = (enabled: boolean) => { + upstreamBillingRateSyncEnabled.value = enabled + if (enabled) { + upstreamBillingAutoProbeEnabled.value = true + } +} + +const handleUpstreamBillingAutoProbeChange = (enabled: boolean) => { + upstreamBillingAutoProbeEnabled.value = enabled + if (!enabled) { + upstreamBillingRateSyncEnabled.value = false + } +} + const statusOptions = computed(() => { const options = [ { value: 'active', label: t('common.active') }, @@ -3273,9 +3397,12 @@ const syncFormFromAccount = (newAccount: Account | null) => { autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true + upstreamBillingRateSyncEnabled.value = + upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false editPlanType.value = '' openAICompactMode.value = 'auto' @@ -3292,6 +3419,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true + openaiFlattenNamespacesEnabled.value = + newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true const longContextBillingValue = extra?.openai_long_context_billing_enabled openAILongContextBillingEnabled.value = longContextBillingValue === true // plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填 @@ -3423,6 +3552,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { loadQuotaControlSettings(newAccount) loadTempUnschedRules(credentials) + loadAccountSchedulingThresholdOverride(newAccount.platform, credentials) // Load header override state (anthropic/openai apikey + grok apikey/oauth) headerOverrideEnabled.value = false @@ -3785,6 +3915,69 @@ const applyTempUnschedConfig = (credentials: Record) => { return true } + +function supportsAccountSchedulingThresholdOverridePlatform(platform: Account['platform'] | undefined) { + return platform === 'openai' || platform === 'anthropic' || platform === 'grok' +} + +function normalizeAccountSchedulingThresholdOverride(value: unknown): number | null { + if (value === null || value === undefined || value === '') { + return null + } + const numeric = Number(value) + if (!Number.isFinite(numeric)) { + return null + } + const integer = Math.trunc(numeric) + if (integer < 1 || integer > 100) { + return null + } + return integer +} + +function clampAccountSchedulingThresholdOverride(value: unknown): number { + return Math.min(100, Math.max(1, Math.trunc(Number(value) || 100))) +} + +function loadAccountSchedulingThresholdOverride( + platform: Account['platform'] | undefined, + credentials: Record | undefined +) { + if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) { + accountSchedulingThresholdOverrideEnabled.value = false + accountSchedulingThresholdOverrideValue.value = 100 + return + } + const value = normalizeAccountSchedulingThresholdOverride( + credentials?.[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] + ) + accountSchedulingThresholdOverrideEnabled.value = value !== null + accountSchedulingThresholdOverrideValue.value = value ?? 100 +} + +const applyAccountSchedulingThresholdOverridePatch = ( + credentials: Record, + currentCredentials: Record, + platform: Account['platform'] | undefined = props.account?.platform +) => { + if (!supportsAccountSchedulingThresholdOverridePlatform(platform)) { + return + } + const current = normalizeAccountSchedulingThresholdOverride( + currentCredentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] + ) + if (!accountSchedulingThresholdOverrideEnabled.value) { + if (current !== null) { + credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = null + } + return + } + const next = clampAccountSchedulingThresholdOverride(accountSchedulingThresholdOverrideValue.value) + if (current !== next) { + credentials[ACCOUNT_SCHEDULING_THRESHOLD_CREDENTIAL_KEY] = next + } +} + function loadTempUnschedRules(credentials?: Record) { tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true const rawRules = credentials?.temp_unschedulable_rules @@ -4050,6 +4243,13 @@ const handleSubmit = async () => { updatePayload.load_factor = 0 } updatePayload.auto_pause_on_expired = autoPauseOnExpired.value + if (props.account.type === 'apikey') { + updatePayload.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value + updatePayload.upstream_billing_rate_sync_enabled = upstreamBillingRateSyncEnabled.value + if (upstreamBillingRateSyncEnabled.value) { + delete updatePayload.rate_multiplier + } + } // For apikey type, handle credentials update if (props.account.type === 'apikey') { @@ -4137,6 +4337,7 @@ const handleSubmit = async () => { // Add intercept warmup requests setting applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4155,6 +4356,7 @@ const handleSubmit = async () => { // Add intercept warmup requests setting applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4203,6 +4405,7 @@ const handleSubmit = async () => { } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4260,6 +4463,7 @@ const handleSubmit = async () => { } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4271,6 +4475,7 @@ const handleSubmit = async () => { const newCredentials: Record = { ...currentCredentials } applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') + applyAccountSchedulingThresholdOverridePatch(newCredentials, currentCredentials) if (!applyTempUnschedConfig(newCredentials)) { return } @@ -4526,6 +4731,12 @@ const handleSubmit = async () => { delete newExtra.openai_passthrough delete newExtra.openai_oauth_passthrough } + // 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项 + if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) { + newExtra.openai_responses_flatten_namespaces = true + } else { + delete newExtra.openai_responses_flatten_namespaces + } if (isSparkShadow.value) { delete newExtra.openai_long_context_billing_enabled } else { @@ -4542,7 +4753,6 @@ const handleSubmit = async () => { } else { newExtra.openai_responses_mode = openAIResponsesMode.value } - newExtra.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value } if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) { newExtra.auto_pause_5h_threshold = autoPause5hThreshold.value / 100 @@ -4607,6 +4817,12 @@ const handleSubmit = async () => { const currentExtra = (updatePayload.extra as Record) || (props.account.extra as Record) || {} const newExtra: Record = { ...currentExtra } + // 上游倍率自动探测对全部 API-key 平台开放(sub2api 上游即可应答), + // Bedrock 凭证无静态 Key 不参与。 + if (props.account.type === 'apikey') { + delete newExtra.upstream_billing_probe_enabled + delete newExtra.upstream_billing_rate_sync_enabled + } // Total quota if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { newExtra.quota_limit = editQuotaLimit.value diff --git a/frontend/src/components/account/GrokQuotaProbeCell.vue b/frontend/src/components/account/GrokQuotaProbeCell.vue index fa6fe29bec..e5f84077a2 100644 --- a/frontend/src/components/account/GrokQuotaProbeCell.vue +++ b/frontend/src/components/account/GrokQuotaProbeCell.vue @@ -24,18 +24,13 @@ {{ t('admin.accounts.usageWindow.grokProbe') }} - -
-
+ +
{{ summary }}
@@ -48,12 +43,17 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' -import type { GrokQuotaProbeResult, GrokQuotaWindow } from '@/api/admin/grok' +import type { GrokQuotaProbeResult } from '@/api/admin/grok' import type { Account } from '@/types' -const props = defineProps<{ - account: Account -}>() +const props = withDefaults( + defineProps<{ + account: Account + /** When true, only show the probe button (+ errors). No duplicate weekly summary. */ + compact?: boolean + }>(), + { compact: false } +) const emit = defineEmits<{ probed: [result: GrokQuotaProbeResult] }>() @@ -79,42 +79,16 @@ const extractErrorMessage = (e: unknown): string => { ) } -const formatWindow = (label: string, window?: GrokQuotaWindow | null): string | null => { - if (!window || window.limit == null || window.remaining == null) return null - return `${label} ${window.remaining}/${window.limit}` -} - -const retryAfterLabel = computed(() => { - const seconds = data.value?.snapshot?.retry_after_seconds - if (seconds == null || seconds <= 0) return null - if (seconds < 60) return `${seconds}s` - return `${Math.ceil(seconds / 60)}m` -}) - const summary = computed(() => { - const snapshot = data.value?.snapshot - if (!data.value) return '' + if (props.compact || !data.value) return '' + // Non-compact fallback (rarely used): brief weekly percent if present. const billing = data.value.billing - const parts: Array = [] if (billing?.period_type?.toLowerCase() === 'weekly' && billing.usage_percent != null) { - parts.push(t('admin.accounts.usageWindow.grokWeeklyUsage', { + return t('admin.accounts.usageWindow.grokWeeklyUsage', { percent: Math.round(Math.min(100, Math.max(0, billing.usage_percent))) - })) + }) } - if (snapshot) { - parts.push( - formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests), - formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens) - ) - } - if (retryAfterLabel.value) { - parts.push(t('admin.accounts.usageWindow.grokRetryAfter', { time: retryAfterLabel.value })) - } - if (snapshot?.entitlement_status) { - parts.push(snapshot.entitlement_status) - } - const visibleParts = parts.filter((part): part is string => Boolean(part)) - return visibleParts.length > 0 ? visibleParts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders') + return '' }) const truncatedError = computed(() => { diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index f45aeeb5d7..0c16efb857 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -59,6 +59,17 @@ t(getOAuthKey('ssoCookieAuth')) }} +
+ +
+
+

+ {{ t(getOAuthKey('emailPasswordDesc')) }} +

+
+ + +

+ {{ t(getOAuthKey('emailPasswordHint')) }} +

+
+
+

+ {{ error }} +

+
+ +
+
+
(), { showAgentIdentityOption: false, showCodexPatOption: false, showSsoOption: false, + showEmailPasswordOption: false, showManualOption: true, initialInputMethod: 'manual', + initialEmailPassword: '', platform: 'anthropic', showProjectId: true }) @@ -877,10 +971,15 @@ const emit = defineEmits<{ 'import-codex-session': [content: string] 'import-codex-pat': [accessToken: string] 'import-sso': [content: string] + 'authorize-password': [emailPasswordInput: string] 'update:inputMethod': [method: AuthInputMethod] }>() const { t } = useI18n() +const passwordAuthEnabled = ref(false) +const emailPasswordOptionEnabled = computed( + () => props.showEmailPasswordOption && props.platform === 'grok' && passwordAuthEnabled.value +) const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok') @@ -922,10 +1021,30 @@ const sessionTokenInput = ref('') const codexSessionInput = ref('') const codexPATInput = ref('') const ssoCookieInput = ref('') +const emailPasswordInput = ref(props.initialEmailPassword || '') const showHelpDialog = ref(false) const oauthState = ref('') const projectId = ref('') +watch( + () => [props.platform, props.showEmailPasswordOption] as const, + async ([platform, requested]) => { + passwordAuthEnabled.value = false + if (platform !== 'grok' || !requested) return + try { + const capabilities = await adminAPI.grok.getCapabilities() + passwordAuthEnabled.value = capabilities.password_auth_enabled + } catch { + // Fail closed; the backend enforces the same capability. + } + }, + { immediate: true } +) + +watch(emailPasswordOptionEnabled, (enabled) => { + if (!enabled && inputMethod.value === 'email_password') inputMethod.value = 'manual' +}) + // Computed: show method selection only when there is something to choose. const methodOptionCount = computed(() => [ props.showManualOption, @@ -937,7 +1056,8 @@ const methodOptionCount = computed(() => [ props.showCodexSessionImportOption, props.showAgentIdentityOption, props.showCodexPatOption, - props.showSsoOption + props.showSsoOption, + emailPasswordOptionEnabled.value ].filter(Boolean).length) const showMethodSelection = computed(() => methodOptionCount.value > 1) @@ -977,11 +1097,34 @@ const parsedSSOCount = computed(() => { .filter((item) => item).length }) +const parsedEmailPasswordCount = computed(() => { + return emailPasswordInput.value + .split('\n') + .map((item) => item.trim()) + .filter((item) => item && item.includes('----')).length +}) + +const handleAuthorizePassword = () => { + if (emailPasswordInput.value.trim()) { + emit('authorize-password', emailPasswordInput.value) + } +} + // Watchers watch(() => props.initialInputMethod, (newVal) => { inputMethod.value = newVal }) +watch( + () => props.initialEmailPassword, + (newVal) => { + // Only prefill when the field is empty so we never overwrite operator input. + if (newVal && !emailPasswordInput.value.trim()) { + emailPasswordInput.value = newVal + } + } +) + watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) }) @@ -1081,6 +1224,7 @@ defineExpose({ codexSession: codexSessionInput, codexPAT: codexPATInput, ssoCookie: ssoCookieInput, + emailPassword: emailPasswordInput, inputMethod, reset: () => { authCodeInput.value = '' @@ -1092,6 +1236,7 @@ defineExpose({ codexSessionInput.value = '' codexPATInput.value = '' ssoCookieInput.value = '' + emailPasswordInput.value = '' inputMethod.value = props.initialInputMethod showHelpDialog.value = false } diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index c1b5e1eec3..cb81bcfd9d 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -19,7 +19,7 @@ class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30" :disabled="loading || resetting" :title="countButtonTitle" - @click="handleQuery" + @click="handleQuery()" > {{ truncatedError }}
+
+ {{ resetWarning }} +
() +const emit = defineEmits<{ + 'account-updated': [account: Account] +}>() + const { t } = useI18n() // Visible only for OpenAI OAuth accounts. @@ -156,17 +166,67 @@ const loading = ref(false) const resetting = ref(false) const error = ref(null) const data = ref(null) +const cachedData = ref(null) const resetMessage = ref(null) +const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +// Rehydrate the card from the persisted snapshot. Credits that already expired +// are dropped and the count is clamped to what remains: the snapshot has no +// freshness signal, so an unfiltered read would offer to consume credits that no +// longer exist. A snapshot claiming credits with no usable expiration left is +// treated as absent, which keeps the reset button gated on a live query. +const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => { + const cached = account.extra?.codex_reset_credit_snapshot + if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null + + const { available_count: count, credits: rawCredits } = cached as { + available_count?: unknown + credits?: unknown + } + if (typeof count !== 'number' || !Number.isFinite(count)) return null + + const now = Date.now() + const credits: { expires_at?: string }[] = [] + if (Array.isArray(rawCredits)) { + for (const credit of rawCredits) { + if (!credit || typeof credit !== 'object') continue + const expiresAt = (credit as { expires_at?: unknown }).expires_at + if (typeof expiresAt !== 'string' || expiresAt.trim() === '') continue + const expiryTime = new Date(expiresAt).getTime() + // Unparsable timestamps are kept: they are already rendered verbatim and + // dropping them would silently understate the available count. + if (!Number.isNaN(expiryTime) && expiryTime <= now) continue + credits.push({ expires_at: expiresAt }) + } + } + const availableCount = Math.min(Math.max(count, 0), credits.length) + // A snapshot that claimed credits but has none left is no longer informative; + // report "unknown" so the operator re-queries instead of trusting it. + if (count > 0 && availableCount <= 0) return null + return { + fetched_at: 0, + rate_limit_reset_credits: { + available_count: availableCount, + credits + } + } +} + +cachedData.value = readCachedResetCredits(props.account) +data.value = cachedData.value + // 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409); // 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。 const isShadow = computed(() => props.account.parent_account_id != null) const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0) +// Prefer the live payload and fall back to the persisted snapshot only when the +// live state is unknown, so the count and the expirations never come from two +// different generations of the same data. const resetCreditExpirations = computed(() => - (data.value?.rate_limit_reset_credits?.credits ?? []) + ((data.value ?? cachedData.value)?.rate_limit_reset_credits?.credits ?? []) .map((credit) => credit.expires_at?.trim() ?? '') .filter((expiresAt) => expiresAt.length > 0) .sort(compareResetCreditExpiry) @@ -265,9 +325,19 @@ const handleQuery = async () => { loading.value = true error.value = null resetMessage.value = null + resetWarning.value = null showResetCreditDetails.value = false try { - data.value = await queryOpenAIQuota(props.account.id) + const result = await refreshOpenAIQuota(props.account.id) + // The upstream read succeeded even when the snapshot write was rejected, so + // the live count is always adopted. Only the persisted view is left alone, + // which keeps the displayed expirations consistent with what is stored. + data.value = result + if (result.cache_persisted) { + cachedData.value = result + } else { + resetWarning.value = t('admin.accounts.openaiQuotaReset.refreshCachePersistFailed') + } } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -294,15 +364,32 @@ const confirmReset = async () => { resetting.value = true error.value = null resetMessage.value = null + resetWarning.value = null try { const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id) - // Refresh the reset-credit count so the badge reflects the consumed credit. - // handleQuery clears resetMessage on entry, so the success toast is set - // AFTER it resolves. - await handleQuery() - resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { - windows: result.windows_reset - }) + showResetCreditDetails.value = false + if (result.cache_refreshed && result.quota) { + data.value = result.quota + cachedData.value = result.quota + } else { + // A credit was consumed but the post-reset count could not be read back. + // Whatever we still hold is one generation stale, so report the count as + // unknown instead of letting a second consumption start from stale data. + data.value = null + } + if (result.account) emit('account-updated', result.account) + + if (result.warning_code === 'reset_credit_cache_refresh_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed') + } else if (result.warning_code === 'account_state_recovery_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed') + } else if (result.warning_code === 'account_state_refresh_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRefreshFailed') + } else { + resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { + windows: result.windows_reset + }) + } } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -314,9 +401,11 @@ watch( () => props.account.id, () => { // Account row may be reused across paginated lists; reset local state. - data.value = null + cachedData.value = readCachedResetCredits(props.account) + data.value = cachedData.value error.value = null resetMessage.value = null + resetWarning.value = null loading.value = false resetting.value = false showResetConfirm.value = false diff --git a/frontend/src/components/account/TempUnschedStatusModal.vue b/frontend/src/components/account/TempUnschedStatusModal.vue index a3e64c487e..bc1ed1685c 100644 --- a/frontend/src/components/account/TempUnschedStatusModal.vue +++ b/frontend/src/components/account/TempUnschedStatusModal.vue @@ -101,6 +101,14 @@ {{ state?.error_message || '-' }}
+ +
+ {{ triggerEvidenceText }} +
@@ -176,10 +184,28 @@ const isActive = computed(() => { }) const ruleIndexDisplay = computed(() => { - if (!state.value) return '-' + if (!state.value || !state.value.matched_keyword || state.value.rule_index < 0) return '-' return state.value.rule_index + 1 }) +const hasThresholdEvidence = computed(() => (state.value?.trigger_count || 0) > 1) + +const triggerEvidenceText = computed(() => { + const count = state.value?.trigger_count || 0 + const threshold = state.value?.trigger_threshold || 0 + const minutes = state.value?.trigger_window_minutes || 0 + if (threshold > 0 && minutes > 0) { + return t('admin.accounts.tempUnschedulable.multipleErrorTrigger', { count, threshold, minutes }) + } + if (threshold > 0) { + return t('admin.accounts.tempUnschedulable.multipleErrorTriggerNoWindow', { count, threshold }) + } + if (minutes > 0) { + return t('admin.accounts.tempUnschedulable.multipleErrorCountInWindow', { count, minutes }) + } + return t('admin.accounts.tempUnschedulable.multipleErrorCount', { count }) +}) + const triggeredAtText = computed(() => { if (!state.value?.triggered_at_unix) return '-' return formatDateTime(new Date(state.value.triggered_at_unix * 1000)) diff --git a/frontend/src/components/account/UpstreamBillingRateCell.vue b/frontend/src/components/account/UpstreamBillingRateCell.vue index f0c86dcabb..6bde43e81b 100644 --- a/frontend/src/components/account/UpstreamBillingRateCell.vue +++ b/frontend/src/components/account/UpstreamBillingRateCell.vue @@ -88,6 +88,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import HelpTooltip from '@/components/common/HelpTooltip.vue' import Icon from '@/components/icons/Icon.vue' +import { formatMultiplier } from '@/utils/formatters' import type { Account, UpstreamBillingProbeSnapshot } from '@/types' const props = withDefaults(defineProps<{ @@ -105,7 +106,8 @@ defineEmits<{ const { t } = useI18n() const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000 -const eligible = computed(() => props.account.platform === 'openai' && props.account.type === 'apikey') +// 探测资格已放宽到全部 API-key 平台(上游是 sub2api 即可应答)。 +const eligible = computed(() => props.account.type === 'apikey') const snapshot = computed(() => props.account.extra?.upstream_billing_probe) const data = computed(() => snapshot.value?.data) const probeEnabled = computed(() => props.account.extra?.upstream_billing_probe_enabled === true) @@ -190,7 +192,7 @@ const elapsedSinceLastSuccess = computed(() => { const effectiveRate = computed(() => { if (!validTimestamps.value || stale.value || !['ok', 'failed'].includes(snapshot.value?.status ?? '')) return '-' const value = currentEffectiveRate.value - return value == null ? '-' : `${Number(value.toPrecision(12))}x` + return value == null ? '-' : `${formatMultiplier(value)}x` }) const statusLabel = computed(() => { if (!snapshot.value) return t('admin.accounts.upstreamBilling.notProbed') diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 70c32ee480..46f3908254 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -512,6 +512,50 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('5h|0|200') }) + it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 0, + resets_at: null, + remaining_seconds: 0 + }, + seven_day: null + }) + const account = makeAccount({ + id: 2004, + platform: 'openai', + type: 'oauth', + updated_at: '2026-03-07T10:00:00Z', + extra: {} + }) + const wrapper = mount(AccountUsageCell, { + props: { account }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true, + OpenAIQuotaResetCell: { + props: ['account'], + emits: ['account-updated'], + template: '` - } - } - } - }) - - await flushPromises() - await wrapper.get('.probe').trigger('click') - - expect(wrapper.text()).toContain('7d|42|2026-07-17T00:00:00Z') - expect(wrapper.text()).toContain('1.0M') - expect(wrapper.text()).not.toContain('750.0K') - expect(wrapper.text()).toContain('ACTIVE') - expect(wrapper.text()).not.toContain('stale error') - }) - - it('Grok successful probes immediately clear stale forbidden state', async () => { - getUsage.mockResolvedValue({ - is_forbidden: true, - forbidden_reason: 'stale forbidden response', - forbidden_type: 'validation', - validation_url: 'https://example.com/verify', - needs_verify: true, - is_banned: true, - grok_entitlement_status: 'forbidden', - grok_quota_snapshot_state: 'no_headers', - error: 'stale forbidden response', - error_code: 'forbidden' - }) - - const wrapper = mount(AccountUsageCell, { - props: { - account: makeAccount({ id: 4503, platform: 'grok', type: 'oauth', extra: {} }) - }, - global: { - stubs: { - UsageProgressBar: true, - AccountQuotaInfo: true, - GrokQuotaProbeCell: true - } - } - }) - - await flushPromises() - expect(wrapper.text()).toContain('forbidden') - - const setupState = wrapper.vm.$.setupState as { - handleGrokProbed: (result: Record) => void - usageInfo: Record | null - } - setupState.handleGrokProbed({ - source: 'active_probe', - snapshot: { - headers_observed: false, - updated_at: '2026-07-18T00:00:00Z', - status_code: 200 - }, - status_code: 200, - headers_observed: false, - reset_supported: false, - fetched_at: 1 - }) - await wrapper.vm.$nextTick() - - expect(setupState.usageInfo).toMatchObject({ - is_forbidden: false, - needs_verify: false, - is_banned: false, - grok_last_status_code: 200 - }) - expect(setupState.usageInfo?.forbidden_reason).toBeUndefined() - expect(setupState.usageInfo?.forbidden_type).toBeUndefined() - expect(setupState.usageInfo?.validation_url).toBeUndefined() - expect(setupState.usageInfo?.grok_entitlement_status).toBeUndefined() - expect(wrapper.text()).not.toContain('admin.accounts.forbidden') - }) - - it('Grok successful probes preserve the entitlement reported by the latest snapshot', async () => { - getUsage.mockResolvedValue({ - is_forbidden: true, - grok_entitlement_status: 'forbidden' - }) - - const wrapper = mount(AccountUsageCell, { - props: { - account: makeAccount({ id: 4504, platform: 'grok', type: 'oauth', extra: {} }) - }, - global: { - stubs: { - UsageProgressBar: true, - AccountQuotaInfo: true, - GrokQuotaProbeCell: true - } - } - }) - - await flushPromises() - - const setupState = wrapper.vm.$.setupState as { - handleGrokProbed: (result: Record) => void - usageInfo: Record | null - } - setupState.handleGrokProbed({ - source: 'active_probe', - snapshot: { - headers_observed: true, - updated_at: '2026-07-18T00:00:00Z', - entitlement_status: 'ACTIVE', - status_code: 200 - }, - status_code: 200, - headers_observed: true, - reset_supported: false, - fetched_at: 1 - }) - await wrapper.vm.$nextTick() - - expect(setupState.usageInfo?.grok_entitlement_status).toBe('ACTIVE') - expect(wrapper.text()).toContain('ACTIVE') - expect(wrapper.text()).not.toContain('admin.accounts.forbidden') - }) - - it('Grok billing-only success does not clear an active-probe forbidden state', async () => { - getUsage.mockResolvedValue({ - is_forbidden: true, - forbidden_type: 'forbidden', - needs_verify: true, - is_banned: true, - grok_entitlement_status: 'forbidden' - }) - - const wrapper = mount(AccountUsageCell, { - props: { - account: makeAccount({ id: 4505, platform: 'grok', type: 'oauth', extra: {} }) - }, - global: { - stubs: { - UsageProgressBar: true, - AccountQuotaInfo: true, - GrokQuotaProbeCell: true - } - } - }) - - await flushPromises() - - const setupState = wrapper.vm.$.setupState as { - handleGrokProbed: (result: Record) => void - usageInfo: Record | null - } - setupState.handleGrokProbed({ - source: 'billing_probe', - billing: { - period_type: 'weekly', - usage_percent: 10, - plan: 'SuperGrok' - }, - status_code: 200, - headers_observed: false, - reset_supported: false, - fetched_at: 1 - }) - await wrapper.vm.$nextTick() - - expect(setupState.usageInfo).toMatchObject({ - is_forbidden: true, - forbidden_type: 'forbidden', - needs_verify: true, - is_banned: true, - grok_entitlement_status: 'forbidden' - }) - expect(wrapper.text()).toContain('forbidden') - }) - - it('Grok Free manual probes merge rolling 24h usage', async () => { - getUsage.mockResolvedValue({ - grok_free_token_limit: 1_000_000, - subscription_tier: 'FREE', - grok_quota_snapshot_state: 'no_headers' - }) - - const wrapper = mount(AccountUsageCell, { - props: { - account: makeAccount({ id: 4502, platform: 'grok', type: 'oauth', extra: {} }) - }, - global: { - stubs: { - UsageProgressBar: { - props: ['label', 'utilization'], - template: '
{{ label }}|{{ utilization }}
' - }, - AccountQuotaInfo: true, - GrokQuotaProbeCell: { - emits: ['probed'], - template: `` - } - } - } - }) - - await flushPromises() - await wrapper.get('.probe').trigger('click') - - expect(wrapper.text()).toContain('24h|75') - expect(wrapper.text()).toContain('750.0K') - expect(wrapper.text()).not.toContain('7d|') - }) - it('Key 账号在 today stats loading 时显示骨架屏', async () => { const wrapper = mount(AccountUsageCell, { props: { @@ -1380,7 +1066,6 @@ describe('AccountUsageCell', () => { template: '
{{ label }}|{{ utilization }}
' }, AccountQuotaInfo: true, - GrokQuotaProbeCell: true } } }) @@ -1424,7 +1109,6 @@ describe('AccountUsageCell', () => { template: '
{{ label }}|{{ utilization }}
' }, AccountQuotaInfo: true, - GrokQuotaProbeCell: true } } }) diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index cedabc58d7..45b8473cbf 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -4,9 +4,13 @@ import BulkEditAccountModal from '../BulkEditAccountModal.vue' import ModelWhitelistSelector from '../ModelWhitelistSelector.vue' import { adminAPI } from '@/api/admin' +const { showError } = vi.hoisted(() => ({ + showError: vi.fn() +})) + vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showError: vi.fn(), + showError, showSuccess: vi.fn(), showInfo: vi.fn() }) @@ -77,6 +81,7 @@ describe('BulkEditAccountModal', () => { beforeEach(() => { vi.mocked(adminAPI.accounts.bulkUpdate).mockReset() vi.mocked(adminAPI.accounts.checkMixedChannelRisk).mockReset() + showError.mockReset() vi.mocked(adminAPI.accounts.bulkUpdate).mockResolvedValue({ success: 2, @@ -88,6 +93,33 @@ describe('BulkEditAccountModal', () => { } as any) }) + it('批量修改倍率时提示自动同步账号需要先关闭同步', async () => { + const wrapper = mountModal() + + expect(wrapper.find('[data-testid="bulk-rate-sync-warning"]').exists()).toBe(false) + await wrapper.get('#bulk-edit-rate-multiplier-enabled').setValue(true) + + expect(wrapper.get('[data-testid="bulk-rate-sync-warning"]').text()).toContain( + 'admin.accounts.bulkEdit.rateSyncWarning' + ) + }) + + it('后端拒绝修改同步账号倍率时展示专用错误', async () => { + vi.mocked(adminAPI.accounts.bulkUpdate).mockRejectedValueOnce({ + status: 409, + reason: 'UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT', + metadata: { count: '2' }, + message: 'conflict' + }) + const wrapper = mountModal() + + await wrapper.get('#bulk-edit-rate-multiplier-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.rateSyncConflict') + }) + it('antigravity 白名单包含 Gemini 图片模型且过滤掉普通 GPT 模型', async () => { const wrapper = mountModal() const selector = wrapper.findComponent(ModelWhitelistSelector) @@ -237,6 +269,34 @@ describe('BulkEditAccountModal', () => { }) }) + it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-flatten-namespaces-toggle').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + openai_responses_flatten_namespaces: true + } + }) + }) + + it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth', 'setup-token'] + }) + + expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false) + }) + it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], @@ -359,6 +419,23 @@ describe('BulkEditAccountModal', () => { }) }) + it('非 OpenAI 平台的 API Key 批量编辑同样可开启上游倍率自动探测', async () => { + // 探测已放宽到全部 API-key 平台,混合平台选择只要求类型全为 apikey。 + const wrapper = mountModal({ + selectedPlatforms: ['grok', 'anthropic'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-upstream-billing-auto-probe-enabled').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + upstream_billing_probe_enabled: true + }) + }) + it('OpenAI API Key 批量编辑可统一关闭上游倍率自动探测', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts index 1d0111e42c..c0273e194f 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.grok.spec.ts @@ -23,9 +23,13 @@ describe('CreateAccountModal Grok account types', () => { expect(source).toContain('form.platform === \'grok\' && isOAuthFlow') }) - it('validates and applies upstream config on all three Grok OAuth create paths', () => { - // 授权码兑换 / RT 批量 / SSO 批量 3 处调用(定义为箭头函数,不计入) - expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBe(3) - expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBe(3) + it('validates and applies upstream config on Grok OAuth create paths', () => { + // 授权码兑换 / RT 批量 / SSO 批量(密码授权已隐藏) + expect(source.match(/validateGrokOAuthUpstreamConfig\(\)/g)?.length).toBeGreaterThanOrEqual(3) + expect(source.match(/applyGrokOAuthUpstreamConfig\(credentials\)/g)?.length).toBeGreaterThanOrEqual(3) + }) + + it('hides Grok password authorize option in the create flow', () => { + expect(source).toContain(':show-email-password-option="false"') }) }) diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 13035a9d3b..d70e7ec6d2 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -167,6 +167,21 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 + it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + true + ) + + await selectButtonByText(wrapper, 'API Key') + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('enables upstream billing probes by default for new OpenAI API key accounts', async () => { await submitApiKeyAccount('openai') @@ -239,7 +254,39 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock).toHaveBeenCalledTimes(1) expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined() - expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBeUndefined() + // 上游倍率探测已放宽到全部 API-key 平台:非 OpenAI 平台与 OpenAI 一致,默认开启。 + expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(true) + }) + + it('sends an explicit disabled state when the non-OpenAI create toggle is turned off', async () => { + await submitApiKeyAccount('anthropic', false, true) + + expect(createAccountMock.mock.calls[0]?.[0]?.upstream_billing_probe_enabled).toBe(false) + }) + + it('antigravity upstream 创建默认携带上游倍率探测开关', async () => { + // antigravity upstream 走独立创建 helper, + // 也必须与其余 API-key 平台一样默认开启探测并传递开关。 + const wrapper = mountModal() + await selectButtonByText(wrapper, 'Antigravity') + await selectButtonByText(wrapper, 'admin.accounts.types.antigravityApikey') + await wrapper.get('form#create-account-form input[type="text"]').setValue('antigravity relay') + const baseInput = wrapper + .findAll('input') + .find((candidate) => candidate.attributes('placeholder') === 'https://cloudcode-pa.googleapis.com') + expect(baseInput).toBeDefined() + await baseInput?.setValue('https://relay.example') + await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-upstream') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + const payload = createAccountMock.mock.calls[0]?.[0] + expect(payload?.platform).toBe('antigravity') + expect(payload?.type).toBe('apikey') + expect(payload?.upstream_billing_probe_enabled).toBe(true) + // 创建成功后前端立即发起一次首探(与其他 apikey 平台一致)。 + expect(probeUpstreamBillingMock).toHaveBeenCalledWith(42) }) it('leaves Codex session import billing ownership to the backend', async () => { diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 8cd20e89ed..e21089313e 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -416,6 +416,57 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('loads and clears the OAuth-only Codex namespace flatten toggle', async () => { + const account = buildAccount() + account.type = 'oauth' + account.extra = { + openai_responses_flatten_namespaces: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]') + + // 关闭后应从 extra 中删除该键,而不是写入 false + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty( + 'openai_responses_flatten_namespaces' + ) + }) + + it('submits the Codex namespace flatten toggle when switched on', async () => { + const account = buildAccount() + account.type = 'oauth' + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + await wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_flatten_namespaces).toBe( + true + ) + }) + + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { + const account = buildAccount() + const wrapper = mountModal(account) + + expect(wrapper.find('[data-testid="edit-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('defaults legacy OpenAI accounts to long-context billing disabled', async () => { const account = buildAccount() updateAccountMock.mockReset() @@ -603,7 +654,117 @@ describe('EditAccountModal', () => { await wrapper.get('form#edit-account-form').trigger('submit.prevent') expect(updateAccountMock).toHaveBeenCalledTimes(1) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_billing_probe_enabled).toBe(true) + expect(updateAccountMock.mock.calls[0]?.[1]?.upstream_billing_probe_enabled).toBe(true) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty( + 'upstream_billing_probe_enabled' + ) + }) + + it('exposes the upstream billing auto-probe toggle for non-OpenAI API-key accounts', async () => { + // 探测已放宽到全部 API-key 平台:grok 账号同样能开启并保存。 + const account = buildAccount() + account.platform = 'grok' + account.name = 'grok-relay' + account.credentials = { api_key: 'sk-grok', base_url: 'https://relay.example/v1' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]') + expect(toggle.attributes('aria-checked')).toBe('false') + + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.upstream_billing_probe_enabled).toBe(true) + }) + + it('enabling rate sync also enables probing and stops submitting a manual rate', async () => { + const account = buildAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const syncToggle = wrapper.get('[data-testid="upstream-billing-rate-sync"]') + const probeToggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]') + const rateInput = wrapper.get('[data-testid="account-rate-multiplier"]') + expect(syncToggle.attributes('aria-checked')).toBe('false') + expect(probeToggle.attributes('aria-checked')).toBe('false') + expect(rateInput.element.disabled).toBe(false) + expect(wrapper.text()).toContain('admin.accounts.billingRateMultiplierHint') + expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.syncRateManagedHint') + + await syncToggle.trigger('click') + expect(syncToggle.attributes('aria-checked')).toBe('true') + expect(probeToggle.attributes('aria-checked')).toBe('true') + expect(rateInput.element.disabled).toBe(true) + expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.syncRateManagedHint') + expect(wrapper.text()).not.toContain('admin.accounts.billingRateMultiplierHint') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.upstream_billing_probe_enabled).toBe(true) + expect(payload?.upstream_billing_rate_sync_enabled).toBe(true) + expect(payload).not.toHaveProperty('rate_multiplier') + }) + + it('disabling probing also disables rate sync and restores manual rate editing', async () => { + const account = buildAccount() + account.extra = { + upstream_billing_probe_enabled: true, + upstream_billing_rate_sync_enabled: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const syncToggle = wrapper.get('[data-testid="upstream-billing-rate-sync"]') + const probeToggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]') + const rateInput = wrapper.get('[data-testid="account-rate-multiplier"]') + expect(syncToggle.attributes('aria-checked')).toBe('true') + expect(rateInput.element.disabled).toBe(true) + + await probeToggle.trigger('click') + expect(probeToggle.attributes('aria-checked')).toBe('false') + expect(syncToggle.attributes('aria-checked')).toBe('false') + expect(rateInput.element.disabled).toBe(false) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.upstream_billing_probe_enabled).toBe(false) + expect(payload?.upstream_billing_rate_sync_enabled).toBe(false) + expect(payload?.rate_multiplier).toBe(1) + }) + + it('disabling only rate sync keeps automatic probing enabled', async () => { + const account = buildAccount() + account.extra = { + upstream_billing_probe_enabled: true, + upstream_billing_rate_sync_enabled: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + await wrapper.get('[data-testid="upstream-billing-rate-sync"]').trigger('click') + expect(wrapper.get('[data-testid="upstream-billing-auto-probe"]').attributes('aria-checked')).toBe( + 'true' + ) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.upstream_billing_probe_enabled).toBe(true) + expect(payload?.upstream_billing_rate_sync_enabled).toBe(false) + expect(payload?.rate_multiplier).toBe(1) }) it('clears OpenAI APIKey Responses override when set back to auto', async () => { diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index 7b5752128b..f888cbee44 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' +import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import type { Account } from '@/types' -import { queryOpenAIQuota } from '@/api/admin/accounts' +import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts' vi.mock('@/api/admin/accounts', () => ({ - queryOpenAIQuota: vi.fn(), + refreshOpenAIQuota: vi.fn(), resetOpenAIQuota: vi.fn(), })) @@ -20,6 +21,11 @@ vi.mock('vue-i18n', async () => { } }) +// 缓存水合会丢弃已过期的重置卡,因此缓存类用例必须使用未来时间。 +const FUTURE_EXPIRY_EARLY = '2099-07-03T04:05:06Z' +const FUTURE_EXPIRY_LATE = '2099-07-05T04:05:06Z' +const PAST_EXPIRY = '2020-07-03T04:05:06Z' + function makeAccount(overrides: Partial): Account { return { id: 1, @@ -54,7 +60,8 @@ const resetButton = (wrapper: ReturnType) => wrapper.findAll('button')[1] beforeEach(() => { - vi.mocked(queryOpenAIQuota).mockReset() + vi.mocked(refreshOpenAIQuota).mockReset() + vi.mocked(resetOpenAIQuota).mockReset() }) describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { @@ -78,8 +85,71 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { wrapper.unmount() }) + it('从账号 extra 缓存恢复重置卡次数和到期时间', () => { + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 2, + credits: [ + { expires_at: FUTURE_EXPIRY_LATE }, + { expires_at: FUTURE_EXPIRY_EARLY }, + ], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count') + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:') + expect(wrapper.text()).toContain('+1') + expect(resetButton(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it('缓存中的重置卡全部过期时视为未知,不点亮重置入口', () => { + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 1, + credits: [{ expires_at: PAST_EXPIRY }], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:') + const btn = resetButton(wrapper) + expect(btn.attributes('disabled')).toBeDefined() + expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery') + wrapper.unmount() + }) + + it('缓存次数向未过期的明细数量收敛', () => { + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 3, + credits: [ + { expires_at: PAST_EXPIRY }, + { expires_at: FUTURE_EXPIRY_EARLY }, + ], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count1') + expect(wrapper.text()).not.toContain('+1') + expect(resetButton(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => { - vi.mocked(queryOpenAIQuota).mockResolvedValue({ + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ rate_limit_reset_credits: { available_count: 3, credits: [ @@ -89,6 +159,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { ], }, fetched_at: 1770000000, + cache_persisted: true, }) const account = makeAccount({ parent_account_id: null }) @@ -97,7 +168,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { await wrapper.findAll('button')[0].trigger('click') await flushPromises() - expect(queryOpenAIQuota).toHaveBeenCalledWith(1) + expect(refreshOpenAIQuota).toHaveBeenCalledWith(1) expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:') expect(wrapper.text()).toContain('+2') expect(wrapper.text()).not.toContain('not-a-date') @@ -115,7 +186,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { }) it('只有一张重置卡时不显示展开按钮', async () => { - vi.mocked(queryOpenAIQuota).mockResolvedValue({ + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ rate_limit_reset_credits: { available_count: 1, credits: [ @@ -123,6 +194,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { ], }, fetched_at: 1770000000, + cache_persisted: true, }) const account = makeAccount({ parent_account_id: null }) @@ -136,4 +208,139 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:') wrapper.unmount() }) + + // 快照写库被拒绝(上游未返回到期明细)不得吞掉这次成功的上游读取, + // 否则次数永远显示不出来、重置入口被永久禁用。 + it('快照持久化失败时仍显示实时次数并给出警告', async () => { + vi.mocked(refreshOpenAIQuota).mockResolvedValue({ + rate_limit_reset_credits: { available_count: 2 }, + fetched_at: 1770000000, + cache_persisted: false, + }) + + const account = makeAccount({ parent_account_id: null }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + await wrapper.findAll('button')[0].trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count2') + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.refreshCachePersistFailed') + expect(resetButton(wrapper).attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it('重置成功后直接使用响应中的最新缓存并回传恢复后的账号', async () => { + const recoveredAccount = makeAccount({ + parent_account_id: null, + status: 'active', + error_message: null, + }) + vi.mocked(resetOpenAIQuota).mockResolvedValue({ + code: 'success', + windows_reset: 1, + cache_refreshed: true, + account_state_recovered: true, + quota: { + rate_limit_reset_credits: { + available_count: 0, + credits: [], + }, + fetched_at: 1770000000, + }, + account: recoveredAccount, + }) + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 1, + credits: [{ expires_at: FUTURE_EXPIRY_EARLY }], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + await resetButton(wrapper).trigger('click') + wrapper.findComponent(ConfirmDialog).vm.$emit('confirm') + await flushPromises() + + expect(resetOpenAIQuota).toHaveBeenCalledWith(1) + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:') + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess') + expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]]) + wrapper.unmount() + }) + + // 缓存回读失败不影响「账号状态已恢复」这一主目标:恢复后的账号行必须照常回传, + // 否则列表会继续显示已经不存在的限流状态。 + it('缓存刷新失败时仍回传恢复后的账号并把次数标为未知', async () => { + const recoveredAccount = makeAccount({ + parent_account_id: null, + status: 'active', + rate_limit_reset_at: null, + }) + vi.mocked(resetOpenAIQuota).mockResolvedValue({ + code: 'success', + windows_reset: 1, + cache_refreshed: false, + account_state_recovered: true, + warning_code: 'reset_credit_cache_refresh_failed', + account: recoveredAccount, + }) + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 1, + credits: [{ expires_at: FUTURE_EXPIRY_EARLY }], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + await resetButton(wrapper).trigger('click') + wrapper.findComponent(ConfirmDialog).vm.$emit('confirm') + await flushPromises() + + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + // 次数未知(隐藏)但仍展示已持久化的到期明细,重置入口保持禁用直到重新查询。 + expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.count1') + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:') + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed') + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]]) + wrapper.unmount() + }) + + it('账号状态恢复失败时停止后续步骤并提示手动恢复', async () => { + vi.mocked(resetOpenAIQuota).mockResolvedValue({ + code: 'success', + windows_reset: 1, + cache_refreshed: false, + account_state_recovered: false, + warning_code: 'account_state_recovery_failed', + }) + const account = makeAccount({ + parent_account_id: null, + extra: { + codex_reset_credit_snapshot: { + available_count: 1, + credits: [{ expires_at: FUTURE_EXPIRY_EARLY }], + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + await resetButton(wrapper).trigger('click') + wrapper.findComponent(ConfirmDialog).vm.$emit('confirm') + await flushPromises() + + expect(refreshOpenAIQuota).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed') + expect(resetButton(wrapper).attributes('disabled')).toBeDefined() + expect(wrapper.emitted('account-updated')).toBeUndefined() + wrapper.unmount() + }) }) diff --git a/frontend/src/components/account/__tests__/UpstreamBillingRateCell.spec.ts b/frontend/src/components/account/__tests__/UpstreamBillingRateCell.spec.ts index 13fb70478b..e665f38da0 100644 --- a/frontend/src/components/account/__tests__/UpstreamBillingRateCell.spec.ts +++ b/frontend/src/components/account/__tests__/UpstreamBillingRateCell.spec.ts @@ -88,11 +88,11 @@ describe('UpstreamBillingRateCell', () => { } }) - expect(wrapper.text()).toContain('0.6x') + expect(wrapper.text()).toContain('0.60x') await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') }) - expect(wrapper.text()).toContain('0.9x') + expect(wrapper.text()).toContain('0.90x') await wrapper.setProps({ now: Date.parse('2026-07-13T10:00:00Z') }) - expect(wrapper.text()).toContain('0.6x') + expect(wrapper.text()).toContain('0.60x') expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.latest') expect(wrapper.get('[data-testid="upstream-billing-probe"]').text()).toBe('') expect(wrapper.get('[data-testid="upstream-billing-probe"]').attributes('aria-label')).toBe( @@ -132,11 +132,11 @@ describe('UpstreamBillingRateCell', () => { } }) }) - expect(wrapper.text()).toContain('0.6x') + expect(wrapper.text()).toContain('0.60x') expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed') await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') }) - expect(wrapper.text()).toContain('0.9x') + expect(wrapper.text()).toContain('0.90x') expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale') await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00.001Z') }) @@ -268,6 +268,11 @@ describe('UpstreamBillingRateCell', () => { await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click') expect(wrapper.emitted('probe')).toHaveLength(1) + // 探测已放宽到全部 API-key 平台:grok API-key 账号同样可探测。 + await wrapper.setProps({ account: makeAccount({ platform: 'grok' }) }) + await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click') + expect(wrapper.emitted('probe')).toHaveLength(2) + await wrapper.setProps({ account: makeAccount({ type: 'oauth' }) }) expect(wrapper.findAll('button')).toHaveLength(0) expect(wrapper.text()).toBe('-') @@ -307,7 +312,7 @@ describe('UpstreamBillingRateCell', () => { await wrapper.setProps({ account: malformedAccount({}, { received_at: 'not-a-time' }) }) expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale') await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:31:00Z' }) }) - expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('0.6x') + expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('0.60x') await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:36:00Z' }) }) expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('admin.accounts.upstreamBilling.stale') await wrapper.setProps({ account: malformedAccount({}, { fresh_until: '2026-07-12T23:59:00Z' }) }) diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index f3a062d0d6..64844e0955 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -1,26 +1,45 @@ + + diff --git a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts index d85178085f..845d763744 100644 --- a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts +++ b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts @@ -6,10 +6,6 @@ const routeState = vi.hoisted(() => ({ query: {} as Record, })) -const locationState = vi.hoisted(() => ({ - current: { href: 'http://localhost/register?aff=AFF123' } as { href: string }, -})) - vi.mock('vue-router', () => ({ useRoute: () => routeState, })) @@ -28,16 +24,11 @@ vi.mock('vue-i18n', () => ({ describe('EmailOAuthButtons', () => { beforeEach(() => { routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' } - locationState.current = { href: 'http://localhost/register?aff=AFF123' } - Object.defineProperty(window, 'location', { - configurable: true, - value: locationState.current, - }) window.localStorage.clear() window.sessionStorage.clear() }) - it('passes the affiliate code to the email oauth start URL', async () => { + it('emits the GitHub OAuth request with redirect and affiliate parameters', async () => { const wrapper = mount(EmailOAuthButtons, { props: { githubEnabled: true, @@ -53,13 +44,40 @@ describe('EmailOAuthButtons', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe( - '/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123' - ) + expect(wrapper.emitted('start')).toEqual([[ + { + provider: 'github', + params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' } + } + ]]) expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123') expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github') }) + it('emits the Google provider without navigating directly', async () => { + const originalHref = window.location.href + const wrapper = mount(EmailOAuthButtons, { + props: { + githubEnabled: false, + googleEnabled: true, + }, + global: { + stubs: { + GitHubMark: true, + GoogleMark: true, + }, + }, + }) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'google', + params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' } + }) + expect(window.location.href).toBe(originalHref) + }) + it('uses a full-width descriptive button when only GitHub is enabled', () => { const wrapper = mount(EmailOAuthButtons, { props: { diff --git a/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts b/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts new file mode 100644 index 0000000000..5111ac6f66 --- /dev/null +++ b/frontend/src/components/auth/__tests__/OAuthLoginSections.spec.ts @@ -0,0 +1,44 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' +import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue' +import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' + +const routeState = vi.hoisted(() => ({ + query: {} as Record +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('OAuth login sections', () => { + beforeEach(() => { + routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' } + window.sessionStorage.clear() + }) + + it.each([ + ['linuxdo', LinuxDoOAuthSection], + ['dingtalk', DingTalkOAuthSection], + ['oidc', OidcOAuthSection] + ] as const)('emits a %s start request from the original button', async (provider, component) => { + const originalHref = window.location.href + const wrapper = mount(component, { props: { affCode: 'AFF456' } }) + + await wrapper.get('button').trigger('click') + + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider, + params: { redirect: '/billing?plan=pro' } + }) + expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF456') + expect(window.location.href).toBe(originalHref) + }) +}) diff --git a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts index d41c1dd7f0..a1a5841ea5 100644 --- a/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts +++ b/frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts @@ -1,3 +1,4 @@ +import { defineComponent, h } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' @@ -7,6 +8,8 @@ const sendVerifyCode = vi.fn() const sendPendingOAuthVerifyCode = vi.fn() const getPublicSettings = vi.fn() const showError = vi.fn() +const turnstileReset = vi.fn() +const verifyAction = vi.fn() vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -40,12 +43,69 @@ describe('PendingOAuthCreateAccountForm', () => { sendPendingOAuthVerifyCode.mockReset() getPublicSettings.mockReset() showError.mockReset() + turnstileReset.mockReset() + verifyAction.mockReset() getPublicSettings.mockResolvedValue({ turnstile_enabled: false, turnstile_site_key: '' }) }) + it('acquires separate proofs for pending OAuth send-code and create-account', async () => { + getPublicSettings.mockResolvedValue({ + email_verify_enabled: true, + turnstile_enabled: false, + turnstile_site_key: '', + tencent_captcha_enabled: true, + tencent_captcha_app_id: 'tencent-app-id' + }) + sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 }) + verifyAction + .mockResolvedValueOnce({ token: 'ticket-1', randstr: '@rand-1' }) + .mockResolvedValueOnce({ token: 'ticket-2', randstr: '@rand-2' }) + const CaptchaChallengeStub = defineComponent({ + setup(_, { expose }) { + expose({ verifyAction, reset: turnstileReset }) + return () => h('div') + } + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'oidc', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { TurnstileWidget: CaptchaChallengeStub } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123') + await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810') + await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click') + await flushPromises() + + expect(verifyAction).toHaveBeenCalledTimes(2) + expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({ + email: 'user@example.com', + tencent_captcha_ticket: 'ticket-1', + tencent_captcha_randstr: '@rand-1' + }) + expect(wrapper.emitted('submit')).toEqual([ + [ + expect.objectContaining({ + tencentCaptchaTicket: 'ticket-2', + tencentCaptchaRandstr: '@rand-2' + }) + ] + ]) + expect(turnstileReset).toHaveBeenCalledTimes(2) + }) + it('emits trimmed email, password, and verify code on submit', async () => { const wrapper = mount(PendingOAuthCreateAccountForm, { props: { @@ -195,6 +255,38 @@ describe('PendingOAuthCreateAccountForm', () => { expect(wrapper.text()).not.toContain('send failed') }) + it('consumes the captcha proof when sending a verify code fails', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + sendPendingOAuthVerifyCode.mockRejectedValue(new Error('send failed')) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'oidc', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click') + await flushPromises() + + expect(turnstileReset).toHaveBeenCalledOnce() + expect(wrapper.get('[data-testid="oidc-create-account-send-code"]').attributes('disabled')).toBeDefined() + }) + it('requires a turnstile token before sending a verify code when turnstile is enabled', async () => { getPublicSettings.mockResolvedValue({ turnstile_enabled: true, @@ -215,7 +307,8 @@ describe('PendingOAuthCreateAccountForm', () => { global: { stubs: { TurnstileWidget: { - template: '' + template: '', + methods: { reset: vi.fn() } } } } @@ -235,4 +328,99 @@ describe('PendingOAuthCreateAccountForm', () => { turnstile_token: 'turnstile-token' }) }) + + it('requires a turnstile token before submitting when turnstile is enabled', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + // 隐式提交(输入框回车)绕过按钮 disabled,仍不能带着空票据发出请求 + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')).toEqual([ + [ + { + email: 'user@example.com', + password: 'secret-123', + verifyCode: '', + turnstileToken: 'turnstile-token', + invitationCode: undefined + } + ] + ]) + }) + + it('blocks submit again after sending a verify code consumes the turnstile proof', async () => { + getPublicSettings.mockResolvedValue({ + turnstile_enabled: true, + turnstile_site_key: 'site-key' + }) + sendPendingOAuthVerifyCode.mockResolvedValue({ message: 'sent', countdown: 60 }) + + const wrapper = mount(PendingOAuthCreateAccountForm, { + props: { + testIdPrefix: 'linuxdo', + initialEmail: 'user@example.com', + isSubmitting: false + }, + global: { + stubs: { + TurnstileWidget: { + template: '', + methods: { reset: turnstileReset } + } + } + } + }) + + await flushPromises() + await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123') + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click') + await flushPromises() + + // 发码消耗掉票据并 reset 组件,新票据回调前提交必须保持关闭 + expect(turnstileReset).toHaveBeenCalled() + expect(wrapper.get('[data-testid="linuxdo-create-account-submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('form').trigger('submit.prevent') + expect(wrapper.emitted('submit')).toBeUndefined() + + // 新票据回调后恢复可提交,且带上新票据 + await wrapper.get('[data-testid="turnstile-verify"]').trigger('click') + await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click') + + expect(wrapper.emitted('submit')?.[0]?.[0]).toMatchObject({ + turnstileToken: 'turnstile-token' + }) + }) }) diff --git a/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts b/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts index 2f269e0bc5..67ccb47aaa 100644 --- a/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts +++ b/frontend/src/components/auth/__tests__/WechatOAuthSection.spec.ts @@ -9,10 +9,6 @@ const routeState = vi.hoisted(() => ({ query: {} as Record, })) -const locationState = vi.hoisted(() => ({ - current: { href: 'http://localhost/login' } as { href: string }, -})) - let pinia: ReturnType vi.mock('vue-router', () => ({ @@ -73,6 +69,7 @@ function buildPublicSettings(overrides: Partial = {}): WeC contact_info: '', doc_url: '', home_content: '', + compact_home_enabled: false, hide_ccs_import_button: false, payment_enabled: false, table_default_page_size: 20, @@ -104,11 +101,6 @@ describe('WechatOAuthSection', () => { pinia = createPinia() setActivePinia(pinia) routeState.query = { redirect: '/billing?plan=pro' } - locationState.current = { href: 'http://localhost/login' } - Object.defineProperty(window, 'location', { - configurable: true, - value: locationState.current, - }) Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0', @@ -134,9 +126,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + }) }) it('uses mp mode inside the WeChat browser when mp mode is configured', async () => { @@ -156,9 +149,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'mp', redirect: '/billing?plan=pro' } + }) }) it('disables the button outside the WeChat browser when only mp mode is configured', async () => { @@ -177,7 +171,7 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe('http://localhost/login') + expect(wrapper.emitted('start')).toBeUndefined() }) it('disables the button inside the WeChat browser when only open mode is configured', async () => { @@ -200,7 +194,7 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toBe('http://localhost/login') + expect(wrapper.emitted('start')).toBeUndefined() }) it('uses the legacy overall enabled flag when per-mode settings are not present', async () => { @@ -215,9 +209,10 @@ describe('WechatOAuthSection', () => { await wrapper.get('button').trigger('click') - expect(locationState.current.href).toContain( - '/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro' - ) + expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({ + provider: 'wechat', + params: { mode: 'open', redirect: '/billing?plan=pro' } + }) }) it('shows the localized not-configured hint when WeChat OAuth is unavailable', async () => { diff --git a/frontend/src/components/charts/ModelDistributionChart.vue b/frontend/src/components/charts/ModelDistributionChart.vue index 5467b6d5aa..8cf7381237 100644 --- a/frontend/src/components/charts/ModelDistributionChart.vue +++ b/frontend/src/components/charts/ModelDistributionChart.vue @@ -422,6 +422,7 @@ const otherRankingItem = computed(() => { return { user_id: 0, email: '', + username: '', actual_cost: otherActualCost, requests: otherRequests, tokens: otherTokens, @@ -495,7 +496,8 @@ const formatNumber = (value: number): string => { } const getRankingUserLabel = (item: UserSpendingRankingItem): string => { - if (item.email) return item.email + if (item.username?.trim()) return item.username.trim() + if (item.email?.trim()) return item.email.trim() return t('admin.redeem.userPrefix', { id: item.user_id }) } diff --git a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts index cf18f0f6e6..7dd6d705be 100644 --- a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts +++ b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts @@ -30,7 +30,12 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => messages[key] ?? key, + t: (key: string, params?: Record) => { + const message = messages[key] ?? key + return params + ? message.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name])) + : message + }, }), } }) @@ -145,14 +150,15 @@ describe('ModelDistributionChart', () => { expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5) }) - it('renders Others in the spending ranking table and uses a dedicated chart color', async () => { + it('uses the dashboard user label policy and renders Others with a dedicated chart color', async () => { const wrapper = mount(ModelDistributionChart, { props: { modelStats: [], enableRankingView: true, rankingItems: [ - { user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 }, - { user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 }, + { user_id: 1, email: 'alpha@example.com', username: 'alpha', actual_cost: 12, requests: 10, tokens: 1000 }, + { user_id: 2, email: 'beta@example.com', username: ' ', actual_cost: 8, requests: 6, tokens: 600 }, + { user_id: 3, email: ' ', username: '', actual_cost: 0, requests: 0, tokens: 0 }, ], rankingTotalActualCost: 30, rankingTotalRequests: 20, @@ -171,20 +177,25 @@ describe('ModelDistributionChart', () => { const chartData = JSON.parse(wrapper.find('.chart-data').text()) expect(chartData.labels).toEqual([ - '#1 alpha@example.com', + '#1 alpha', '#2 beta@example.com', + '#3 User #3', 'Others', ]) - expect(chartData.datasets[0].data).toEqual([12, 8, 10]) + expect(chartData.datasets[0].data).toEqual([12, 8, 0, 10]) expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6') - expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8') - expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0]) + expect(chartData.datasets[0].backgroundColor[3]).toBe('#94a3b8') + expect(chartData.datasets[0].backgroundColor[3]).not.toBe(chartData.datasets[0].backgroundColor[0]) const rows = wrapper.findAll('tbody tr') - expect(rows).toHaveLength(3) - expect(rows[2].text()).toContain('Others') - expect(rows[2].text()).toContain('4') - expect(rows[2].text()).toContain('400') - expect(rows[2].text()).toContain('$10.00') + expect(rows).toHaveLength(4) + expect(rows[0].text()).toContain('alpha') + expect(rows[0].text()).not.toContain('alpha@example.com') + expect(rows[1].text()).toContain('beta@example.com') + expect(rows[2].text()).toContain('User #3') + expect(rows[3].text()).toContain('Others') + expect(rows[3].text()).toContain('4') + expect(rows[3].text()).toContain('400') + expect(rows[3].text()).toContain('$10.00') }) }) diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index 2a0f25870b..0a025bfce3 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -28,7 +28,7 @@ -

{{ hint }}

@@ -70,9 +70,12 @@ + + diff --git a/frontend/src/features/channel-monitor-v2/MetricCell.vue b/frontend/src/features/channel-monitor-v2/MetricCell.vue new file mode 100644 index 0000000000..aa3eee65eb --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MetricCell.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue new file mode 100644 index 0000000000..14e2932a88 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue new file mode 100644 index 0000000000..3fdf1e584a --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue @@ -0,0 +1,441 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue new file mode 100644 index 0000000000..012eeb4631 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue @@ -0,0 +1,273 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue new file mode 100644 index 0000000000..70e307c468 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue @@ -0,0 +1,664 @@ + + + + + diff --git a/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts new file mode 100644 index 0000000000..420d166279 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import MetricCell from '../MetricCell.vue' + +describe('MetricCell', () => { + it('renders label/value/detail through shipped template with design-system classes', () => { + const wrapper = mount(MetricCell, { + props: { + label: '请求', + value: '1,234', + detail: '12.5 RPM', + state: 'healthy', + }, + }) + + expect(wrapper.classes().join(' ') + wrapper.html()).toContain('stat-card') + expect(wrapper.text()).toContain('请求') + expect(wrapper.text()).toContain('1,234') + expect(wrapper.text()).toContain('12.5 RPM') + expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/) + }) + + it('maps warning and critical health states to distinct colors', () => { + const warning = mount(MetricCell, { + props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' }, + }) + expect(warning.find('strong').classes().join(' ')).toMatch(/amber/) + + const critical = mount(MetricCell, { + props: { label: '错误', value: '50%', detail: '5 次', state: 'critical' }, + }) + expect(critical.find('strong').classes().join(' ')).toMatch(/red/) + }) + + it('renders multi-part detail as non-truncated chips (AVG · P90 fully visible)', () => { + const wrapper = mount(MetricCell, { + props: { + label: '首 Token P50', + value: '400ms', + detail: 'AVG 475ms · P90 800ms', + state: 'healthy', + }, + }) + expect(wrapper.find('small.truncate').exists()).toBe(false) + expect(wrapper.find('.whitespace-nowrap').exists()).toBe(true) + expect(wrapper.text()).toContain('AVG 475ms') + expect(wrapper.text()).toContain('P90 800ms') + expect(wrapper.text()).not.toContain('…') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts new file mode 100644 index 0000000000..e096a5d74a --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts @@ -0,0 +1,176 @@ +const i18nT = (key: string, params?: Record) => { + const map: Record = { + 'channelMonitorV2.matrix.title': '色块矩阵', + 'channelMonitorV2.matrix.description': '趋势色块视图', + 'channelMonitorV2.bucket.minutes': '{count}分钟', + 'channelMonitorV2.matrix.wheelZoomX': '滚轮缩放横向', + 'channelMonitorV2.matrix.resetZoom': '重置缩放', + 'channelMonitorV2.matrix.dimension': '维度', + 'channelMonitorV2.metrics.successRate': '成功率', + 'channelMonitorV2.metrics.ttft': '首 Token', + 'channelMonitorV2.metrics.tps': '每秒 Token', + 'channelMonitorV2.metrics.cacheRate': '缓存率', + 'channelMonitorV2.matrix.scoreLine': '评分 {score}', + 'channelMonitorV2.metrics.successRateValue': '成功率 {value}', + 'channelMonitorV2.metrics.errorRateValue': '错误率 {value}', + 'channelMonitorV2.metrics.rpmValue': 'RPM {value}', + 'channelMonitorV2.metrics.tpmValue': 'TPM {value}', + 'channelMonitorV2.metrics.tpsValue': '每秒 Token {value}', + 'channelMonitorV2.metrics.ttftValue': '首 Token {value}', + 'channelMonitorV2.metrics.durationValue': '时长 {value}', + 'channelMonitorV2.metrics.cacheRateValue': '缓存率 {value}', + 'channelMonitorV2.matrix.noTrafficAt': '{time} 无流量', + 'channelMonitorV2.matrix.noTraffic': '无流量', + 'channelMonitorV2.matrix.legendAria': '图例', + 'channelMonitorV2.matrix.bad': '差', + 'channelMonitorV2.matrix.good': '好', + 'channelMonitorV2.matrix.healthyLegend': '健康', + 'channelMonitorV2.matrix.warningLegend': '警告', + 'channelMonitorV2.matrix.criticalLegend': '异常', + 'channelMonitorV2.matrix.unknownLegend': '未知', + } + const template = map[key] || key + return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? '')) +} + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: i18nT, + te: (key: string) => key.startsWith('channelMonitorV2.'), + locale: { value: 'zh' }, + }), + } +}) + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import RelayPulseMatrix from '../RelayPulseMatrix.vue' +import type { MonitorHealth, MonitorMetric } from '@/api/channelMonitorV2' + +const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, +} + +function metrics(requestCount: number): MonitorMetric { + return { + success_requests: requestCount ? requestCount - 1 : 0, + error_requests: requestCount ? 1 : 0, + request_count: requestCount, + token_count: 100, + rpm: 1.5, + tpm: 10.2, + error_rate: requestCount ? 1 / requestCount : 0, + cache_rate: 0.5, + cache_rate_numerator: 50, + cache_rate_denominator: 100, + ttft: { sample_count: requestCount, p50_ms: 100, p95_ms: 300, avg_ms: 150 }, + duration: { sample_count: requestCount, p50_ms: 500, p95_ms: 900, avg_ms: 600 }, + upstream_affected_requests: 2, + upstream_attempt_count: 3, + } +} + +describe('RelayPulseMatrix', () => { + it('shows privacy-safe hover tooltips and multi-band colors without click modal', async () => { + const wrapper = mount(RelayPulseMatrix, { + props: { + rows: [{ + platform: 'openai', + group_id: 7, + group_name: '默认组', + model: 'gpt-5', + metrics: metrics(10), + health, + buckets: [ + { bucket_start: '2026-08-01T00:00:00Z', metrics: metrics(10), health }, + { bucket_start: '2026-08-01T00:01:00Z', metrics: metrics(0), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:03:00Z', + coverage_start: '2026-08-01T00:00:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: true, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + + const cells = wrapper.findAll('.pulse-cell') + expect(cells).toHaveLength(3) + // Hover tooltip content (privacy-safe: no absolute request/error counts) + const tip = cells[0].text() + expect(tip).toContain('成功率') + expect(tip).toContain('90.0%') + expect(tip).toContain('首 Token') + expect(tip).toContain('每秒 Token') + expect(tip).toContain('缓存率') + expect(tip).toContain('RPM') + expect(tip).not.toContain('请求数') + expect(tip).not.toContain('用户错误') + expect(tip).not.toContain('上游受影响') + // Summary columns: success · ttft · tokens/s · cache + const header = wrapper.find('.matrix-header').text() + expect(header).toContain('成功率') + expect(header).toContain('首 Token') + expect(header).toContain('每秒 Token') + expect(header).toContain('缓存率') + // Multi-band class from score 52 → score5 + expect(cells[0].classes().some((c) => c.startsWith('health-score'))).toBe(true) + // Redacted user payloads may have request_count=0 but still include score. + expect(cells[1].classes().some((c) => c.startsWith('health-score'))).toBe(true) + expect(cells[2].classes()).toContain('health-unknown') + + // No click-to-open modal + await cells[0].trigger('click') + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) + }) +}) + +describe('RelayPulseMatrix axis range', () => { + it('uses selected requested range for the X axis even with partial coverage', () => { + const wrapper = mount(RelayPulseMatrix, { + props: { + rows: [{ + platform: 'openai', + group_id: 7, + group_name: '默认组', + model: 'gpt-5', + metrics: metrics(10), + health, + buckets: [ + { bucket_start: '2026-08-01T00:02:00Z', metrics: metrics(10), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:05:00Z', + coverage_start: '2026-08-01T00:02:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: false, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + // 5 minutes @ 60s buckets → 5 cells spanning the selected range + expect(wrapper.findAll('.pulse-cell')).toHaveLength(5) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts new file mode 100644 index 0000000000..9aa2a23522 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts @@ -0,0 +1,104 @@ +/** + * Structure contracts: channel-monitor-v2 + studio shells must use project + * design-system utility classes rather than isolated flat RGB skins. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(__dirname, '../../..') + +function read(rel: string) { + return readFileSync(resolve(root, rel), 'utf8') +} + +describe('channel-monitor-v2 design system structure', () => { + it('user ChannelStatus V2 shell uses page-header, card, btn, tabs utilities', () => { + // Route wrapper may switch V1/V2; design chrome lives on the V2 implementation. + const src = read('views/user/ChannelStatusV2View.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="card') + expect(src).toContain('btn btn-secondary') + expect(src).toContain('class="tab') + expect(src).toContain('tab-active') + expect(src).toContain('badge badge-warning') + // Compact single-row toolbar + expect(src).toContain('monitor-toolbar') + expect(src).toContain('clearFilters') + expect(src).toContain('healthModeOptions') + expect(src).toContain("'cache'") + // Ops elevation: rounded-3xl + ring surfaces + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + // Overview-first KPI strip before primary viz + expect(src.indexOf('summaryAria')).toBeLessThan(src.indexOf('MonitorTrendChart')) + // No page-level fixed min-width that forces viewport horizontal scroll + expect(src).not.toMatch(/min-width:\s*980px/) + expect(src).not.toMatch(/min-w-\[980px\]/) + // Dense tables scroll internally + expect(src).toMatch(/max-h-\[min\(52vh/) + expect(src).toContain('overflow-auto') + // Trend view toggle (pulse matrix / line chart) + default platform/group dimension + expect(src).toContain("trendView") + expect(src).toContain("'platform_group'") + expect(src).toContain('MonitorTrendChart') + }) + + it('RelayPulseMatrix uses card chrome, matrix scroll, and hover tooltips (no click modal)', () => { + const src = read('features/channel-monitor-v2/RelayPulseMatrix.vue') + expect(src).toContain('class="card') + expect(src).toContain('card-header') + expect(src).toContain('card-body') + expect(src).toContain('matrix-scroll') + expect(src).toMatch(/max-h-\[min\(42vh/) + expect(src).toContain('overflow-auto') + expect(src).toContain('pulse-tooltip') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).not.toContain('modal-overlay') + expect(src).not.toContain('modal-content') + }) + + it('MetricCell uses stat-card utility', () => { + const src = read('features/channel-monitor-v2/MetricCell.vue') + expect(src).toContain('stat-card') + expect(src).toContain('stat-label') + expect(src).toContain('stat-value') + expect(src).toContain('rounded-3xl') + }) + + it('MonitorTrendChart uses Ops chart shell tokens', () => { + const src = read('features/channel-monitor-v2/MonitorTrendChart.vue') + expect(src).toContain('class="card') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).toContain('EmptyState') + expect(src).toContain('min-h-[360px]') + }) + + it('FilterMultiSelect uses rounded-xl input chrome and dropdown utility', () => { + const src = read('features/channel-monitor-v2/FilterMultiSelect.vue') + expect(src).toContain('rounded-xl') + expect(src).toContain('dropdown') + expect(src).toContain('dropdown-item') + }) + + it('MonitorSettingsPanel uses page-header, card, btn-primary, tabs', () => { + const src = read('features/channel-monitor-v2/MonitorSettingsPanel.vue') + expect(src).toContain('page-header') + expect(src).toContain('btn btn-primary') + expect(src).toContain('class="card') + expect(src).toContain('tab-active') + expect(src).toMatch(/max-h-\[min\(40vh/) + }) + + it('admin ChannelMonitorView V2 tab chrome uses project tabs', () => { + const src = read('views/admin/ChannelMonitorView.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="tabs') + expect(src).toContain('tab-active') + expect(src).toContain('MonitorSettingsPanel') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts new file mode 100644 index 0000000000..affbcdcded --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { + formatLatencyKpiSecondary, + formatLatencyPrivacy, + formatMonitorMs, + formatMonitorNumber, + formatMonitorPercent, + formatMonitorRate, + formatMonitorSuccessRate, + formatMonitorSuccessRateFromError, + formatMonitorThroughput, + formatMonitorTokensPerSecond, + healthScoreClass, + healthStateClass, + scoreToBand, + tokensPerSecondFromTpm, +} from '../monitorFormat' +import type { MonitorHealth } from '@/api/channelMonitorV2' + +describe('monitorFormat accuracy', () => { + it('converts backend TPM (per minute) to tokens/sec for display', () => { + expect(tokensPerSecondFromTpm(60)).toBe(1) + expect(tokensPerSecondFromTpm(6000)).toBe(100) + expect(formatMonitorTokensPerSecond(60)).toBe('1') + expect(formatMonitorTokensPerSecond(null)).toBe('0') + }) + + it('formats request/token counts with compact notation above threshold', () => { + expect(formatMonitorNumber(10)).toBe('10') + expect(formatMonitorNumber(9999)).toBe('9,999') + // compact at >= 10000 + expect(formatMonitorNumber(10000)).toMatch(/1/) + expect(formatMonitorNumber(0)).toBe('0') + }) + + it('formats rates and percents from raw API fractions without hardcoding pretty values', () => { + expect(formatMonitorRate(12.56)).toBe('12.6') + expect(formatMonitorPercent(0.1)).toBe('10.0%') + expect(formatMonitorPercent(0.005)).toBe('0.50%') + // value < 0.01 uses 2 decimal places (includes exact 0) + expect(formatMonitorPercent(0)).toBe('0.00%') + expect(formatMonitorPercent(0.5)).toBe('50.0%') + }) + + it('formats RPM/TPM throughput with K/M compact notation', () => { + expect(formatMonitorThroughput(12.56)).toBe('12.6') + expect(formatMonitorThroughput(999)).toMatch(/999/) + expect(formatMonitorThroughput(1000)).toBe('1.0K') + expect(formatMonitorThroughput(1500)).toBe('1.5K') + expect(formatMonitorThroughput(1_000_000)).toBe('1.0M') + expect(formatMonitorThroughput(1_500_000)).toBe('1.5M') + expect(formatMonitorThroughput(0)).toBe('0') + }) + + it('formats latency ms/s boundaries from API fields', () => { + expect(formatMonitorMs(null)).toBe('-') + expect(formatMonitorMs(300)).toBe('300ms') + expect(formatMonitorMs(1500)).toBe('1.5s') + expect(formatMonitorMs(999)).toBe('999ms') + }) + + it('formats KPI secondary latency as AVG · P90 (P50 is primary)', () => { + expect(formatLatencyKpiSecondary(150, 400)).toBe('AVG 150ms · P90 400ms') + expect(formatLatencyKpiSecondary(1500, null, 2000)).toBe('AVG 1.5s · P95 2.0s') + expect(formatLatencyKpiSecondary(null, null)).toBe('-') + }) + + it('computes success rate from success_requests / request_count', () => { + expect(formatMonitorSuccessRate(9, 10)).toBe('90.0%') + expect(formatMonitorSuccessRate(0, 0)).toBe('-') + expect(formatMonitorSuccessRate(1, 3)).toBe('33.3%') + }) + + it('derives success rate from error_rate without absolute counts', () => { + expect(formatMonitorSuccessRateFromError(0.1)).toBe('90.0%') + expect(formatMonitorSuccessRateFromError(0)).toBe('100.0%') + }) + + it('maps continuous scores to multi-stop bands', () => { + expect(scoreToBand(100)).toBe('score10') + expect(scoreToBand(95)).toBe('score10') + expect(scoreToBand(80)).toBe('score8') + expect(scoreToBand(50)).toBe('score5') + expect(scoreToBand(0)).toBe('score0') + expect(scoreToBand(null)).toBe('unknown') + }) + + it('builds score-based health classes for matrix cells', () => { + const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, + } + expect(healthScoreClass(health, 'overall', 10)).toBe('health-score5') + expect(healthScoreClass(health, 'success', 10)).toBe('health-score4') + expect(healthScoreClass(health, 'ttft', 10)).toBe('health-score10') + expect(healthScoreClass(health, 'cache', 10)).toBe('health-score5') + // Redacted user payloads have request_count=0 but keep score fields. + expect(healthScoreClass(health, 'overall', 0)).toBe('health-score5') + expect(healthScoreClass({ ...health, score: null }, 'overall', 0)).toBe('health-unknown') + }) + + it('maps health states for status dots', () => { + expect(healthStateClass('healthy')).toBe('health-healthy') + expect(healthStateClass(undefined)).toBe('health-unknown') + }) + + it('formats privacy-safe latency lines with avg/p50/p90', () => { + expect(formatLatencyPrivacy(100, 250, 120, 300)).toBe('AVG 120ms · P50 100ms · P90 250ms') + expect(formatLatencyPrivacy(100, null, null, 300)).toBe('P50 100ms · P95 300ms') + expect(formatLatencyPrivacy(null, null)).toBe('-') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts new file mode 100644 index 0000000000..3630160ea8 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ZOOM, + MIN_ZOOM_SPAN, + applyWheelZoom, + clampZoom, + isZoomed, + resetZoom, + sliceByZoom, +} from '../monitorZoom' + +describe('monitorZoom', () => { + it('resets to full span', () => { + expect(resetZoom()).toEqual(DEFAULT_ZOOM) + expect(isZoomed(DEFAULT_ZOOM)).toBe(false) + }) + + it('zooms in around cursor without always pinning to the end', () => { + const mid = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0.5) + expect(mid.span).toBeLessThan(1) + expect(mid.span).toBeGreaterThanOrEqual(MIN_ZOOM_SPAN) + // Center zoom keeps window roughly centered + expect(mid.offset).toBeGreaterThan(0) + expect(mid.offset + mid.span).toBeLessThanOrEqual(1.001) + + const left = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0) + expect(left.offset).toBeLessThanOrEqual(0.001) + + const right = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 1) + expect(right.offset + right.span).toBeGreaterThan(0.99) + }) + + it('pans with shift+wheel', () => { + const zoomed = clampZoom({ span: 0.4, offset: 0.1 }) + const panned = applyWheelZoom(zoomed, { deltaY: 200, deltaX: 0, shiftKey: true }, 0.5) + expect(panned.span).toBeCloseTo(zoomed.span, 5) + expect(panned.offset).toBeGreaterThan(zoomed.offset) + }) + + it('clamps span to MIN_ZOOM_SPAN and keeps window in range', () => { + const tight = clampZoom({ span: 0.01, offset: 0.9 }) + expect(tight.span).toBe(MIN_ZOOM_SPAN) + expect(tight.offset + tight.span).toBeLessThanOrEqual(1.001) + }) + + it('slices series by window, not always last N points', () => { + const items = Array.from({ length: 100 }, (_, i) => i) + const startWindow = sliceByZoom(items, { span: 0.2, offset: 0 }) + expect(startWindow[0]).toBe(0) + expect(startWindow.length).toBe(20) + + const midWindow = sliceByZoom(items, { span: 0.2, offset: 0.4 }) + expect(midWindow[0]).toBe(40) + expect(midWindow[midWindow.length - 1]).toBe(59) + + const full = sliceByZoom(items, DEFAULT_ZOOM) + expect(full).toHaveLength(100) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/monitorFormat.ts b/frontend/src/features/channel-monitor-v2/monitorFormat.ts new file mode 100644 index 0000000000..2589e06705 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorFormat.ts @@ -0,0 +1,173 @@ +/** + * Shared display formatters for channel-monitor-v2. + * Kept pure so unit tests can lock metric presentation accuracy. + * + * Privacy: prefer rates (error_rate, RPM, TPM, success %) over absolute + * request/error/token counts in user-facing surfaces. + */ + +import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2' +import { formatCompactNumber } from '@/utils/format' + +export function monitorIntlLocale(): string { + if (typeof document !== 'undefined') { + const htmlLang = document.documentElement.getAttribute('lang')?.trim() + if (htmlLang) return htmlLang + } + if (typeof navigator !== 'undefined' && navigator.language) return navigator.language + return 'zh-CN' +} + +export function formatMonitorNumber(value: number, compactAt = 10000, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { + notation: value >= compactAt ? 'compact' : 'standard', + maximumFractionDigits: 1, + }).format(value || 0) +} + +export function formatMonitorRate(value: number, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value || 0) +} + +/** + * RPM/TPM throughput display: always K/M compact (1.0K, 1.5M) for ops-readable density. + * Prefer this over formatMonitorRate for rate KPIs and table cells. + */ +export function formatMonitorThroughput(value: number | null | undefined): string { + if (value == null || Number.isNaN(Number(value))) return '0' + const n = Number(value) + if (Math.abs(n) < 1000) { + return Intl.NumberFormat(monitorIntlLocale(), { maximumFractionDigits: 1 }).format(n) + } + return formatCompactNumber(n) +} + +/** + * Backend stores tokens-per-minute as `tpm`. Convert to tokens-per-second for display. + */ +export function tokensPerSecondFromTpm(tpm: number | null | undefined): number { + if (tpm == null || Number.isNaN(Number(tpm))) return 0 + return Number(tpm) / 60 +} + +/** Tokens/sec from backend TPM (per-minute), compact ops formatting. */ +export function formatMonitorTokensPerSecond(tpm: number | null | undefined): string { + return formatMonitorThroughput(tokensPerSecondFromTpm(tpm)) +} + + +export function formatMonitorPercent(value: number, locale = monitorIntlLocale()): string { + return `${new Intl.NumberFormat(locale, { + minimumFractionDigits: value < 0.01 ? 2 : 1, + maximumFractionDigits: value < 0.01 ? 2 : 1, + }).format((value || 0) * 100)}%` +} + +export function formatMonitorMs(value: number | null | undefined): string { + if (value == null) return '-' + return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms` +} + +export function formatMonitorSuccessRate(successRequests: number, requestCount: number): string { + if (!requestCount) return '-' + return formatMonitorPercent(successRequests / requestCount) +} + +export function formatMonitorSuccessRateFromError(errorRate: number): string { + return formatMonitorPercent(1 - (errorRate || 0)) +} + +/** + * Map continuous 0–100 score to 11 fine bands for multi-stop green→yellow→red. + * score10 = best (green), score0 = worst (red). + */ +export function scoreToBand(score: number | null | undefined): HealthScoreBand { + if (score == null || Number.isNaN(score)) return 'unknown' + const clamped = Math.max(0, Math.min(100, score)) + // 0–100 → score0..score10 (11 stops for green→yellow→red gradient) + const band = Math.round(clamped / 10) + return `score${Math.max(0, Math.min(10, band))}` as HealthScoreBand +} + +export type HealthDisplayMode = 'overall' | 'success' | 'ttft' | 'cache' + +/** Resolve the score used for a health mode. */ +export function healthModeScore( + health: MonitorHealth, + mode: HealthDisplayMode, +): number | null { + if (mode === 'success') { + return health.error_rate_score ?? null + } + if (mode === 'ttft') { + return health.ttft_score ?? null + } + if (mode === 'cache') { + return health.cache_score ?? null + } + return health.score ?? null +} + +export function healthScoreClass( + health: MonitorHealth, + mode: HealthDisplayMode, + requestCount: number, +): string { + const score = healthModeScore(health, mode) + if (score == null) { + if (requestCount <= 0) return 'health-unknown' + // Fall back to coarse state when score is absent (older payloads). + const coarse = + mode === 'success' + ? health.error_rate + : mode === 'ttft' + ? health.ttft + : mode === 'cache' + ? health.cache + : health.overall + return healthStateClass(coarse) + } + return `health-${scoreToBand(score)}` +} + +export function healthStateClass(state: string | undefined): string { + return `health-${state || 'unknown'}` +} + +/** Privacy-safe latency summary: avg + p50 + p90 (no absolute sample counts). */ +export function formatLatencyPrivacy( + p50: number | null | undefined, + p90: number | null | undefined, + avg?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p50 != null) parts.push(`P50 ${formatMonitorMs(p50)}`) + if (p90 != null) parts.push(`P90 ${formatMonitorMs(p90)}`) + // p95 only as fallback when p90 missing (older payloads) + if (p90 == null && p95 != null) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** + * KPI secondary line for latency: AVG + P90 only (P50 is the primary value). + * Falls back to P95 when P90 is absent. Delimiter is " · " so MetricCell can + * split into non-truncated chips. + */ +export function formatLatencyKpiSecondary( + avg?: number | null | undefined, + p90?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null && Number.isFinite(avg)) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p90 != null && Number.isFinite(p90)) parts.push(`P90 ${formatMonitorMs(p90)}`) + else if (p95 != null && Number.isFinite(p95)) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** Fallback when i18n key is missing; prefer `channelMonitorV2.errorCategories.*`. */ +export function monitorErrorCategoryLabel(category: string): string { + return category +} diff --git a/frontend/src/features/channel-monitor-v2/monitorZoom.ts b/frontend/src/features/channel-monitor-v2/monitorZoom.ts new file mode 100644 index 0000000000..8bd6ff9490 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorZoom.ts @@ -0,0 +1,103 @@ +/** + * Pure X-axis zoom/pan for channel-monitor pulse matrix and line chart. + * No chartjs-plugin-zoom dependency — state is span + offset fractions. + */ + +export interface ZoomState { + /** Visible fraction of the full series (1 = full range). */ + span: number + /** Start of the visible window as a fraction of the full domain [0, 1 - span]. */ + offset: number +} + +export const DEFAULT_ZOOM: ZoomState = { span: 1, offset: 0 } +export const MIN_ZOOM_SPAN = 0.12 + +const ZOOM_STEP = 0.12 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function resetZoom(): ZoomState { + return { ...DEFAULT_ZOOM } +} + +export function isZoomed(state: ZoomState): boolean { + return state.span < 0.999 || state.offset > 0.001 +} + +/** Normalize offset so the visible window stays within [0, 1]. */ +export function clampZoom(state: ZoomState): ZoomState { + const span = clamp(state.span, MIN_ZOOM_SPAN, 1) + const maxOffset = Math.max(0, 1 - span) + return { + span, + offset: clamp(state.offset, 0, maxOffset), + } +} + +/** + * Wheel zoom around cursor xRatio (0–1 within the chart/matrix track). + * Shift+wheel or horizontal delta pans offset instead. + */ +export function applyWheelZoom( + state: ZoomState, + event: Pick, + xRatio: number, +): ZoomState { + const ratio = clamp(Number.isFinite(xRatio) ? xRatio : 0.5, 0, 1) + const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) + + if (isPan) { + if (state.span >= 1) return clampZoom(state) + const delta = event.shiftKey ? event.deltaY : event.deltaX + // Positive delta → pan right (increase offset) + const panFraction = (delta / 400) * state.span + return clampZoom({ + span: state.span, + offset: state.offset + panFraction, + }) + } + + // Zoom in (deltaY < 0) / out (deltaY > 0) around cursor + const direction = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0 + if (direction === 0) return clampZoom(state) + + const nextSpan = clamp(state.span * (1 + direction * ZOOM_STEP), MIN_ZOOM_SPAN, 1) + if (Math.abs(nextSpan - state.span) < 1e-9) return clampZoom(state) + + // Keep the point under the cursor fixed in data space. + // windowStart is offset (domain fraction); dataPos = windowStart + ratio * span + const windowStart = state.offset + const dataPos = windowStart + ratio * state.span + const nextStart = dataPos - ratio * nextSpan + + return clampZoom({ + span: nextSpan, + offset: nextStart, + }) +} + +/** + * Slice an array using zoom state. Cursor-centered windowing — not always "last N". + */ +export function sliceByZoom(items: readonly T[], state: ZoomState): T[] { + if (!items.length) return [] + const zoom = clampZoom(state) + if (zoom.span >= 0.999) return [...items] + + const count = Math.max(1, Math.ceil(items.length * zoom.span)) + const maxStart = Math.max(0, items.length - count) + // offset is window start in [0, 1-span] domain → map to index + const start = Math.min(maxStart, Math.max(0, Math.round(zoom.offset * items.length))) + return items.slice(start, start + count) +} + +/** Map a client X position within an element to 0–1 ratio. */ +export function clientXRatio(clientX: number, element: HTMLElement | null | undefined): number { + if (!element) return 0.5 + const rect = element.getBoundingClientRect() + if (rect.width <= 0) return 0.5 + return clamp((clientX - rect.left) / rect.width, 0, 1) +} diff --git a/frontend/src/features/prompt-audit/PromptAuditView.vue b/frontend/src/features/prompt-audit/PromptAuditView.vue index 8ea9c6cc9d..ee72895215 100644 --- a/frontend/src/features/prompt-audit/PromptAuditView.vue +++ b/frontend/src/features/prompt-audit/PromptAuditView.vue @@ -95,6 +95,7 @@
+
diff --git a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts index f271db9e38..c68fa06f4f 100644 --- a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts @@ -19,7 +19,7 @@ vi.mock('vue-i18n', async () => { }) const baseConfig = (): PromptAuditConfig => ({ - enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', + enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: true, group_ids: [], endpoints: [{ id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true, has_token: true, token_status: 'configured' }], config_version: 7, updated_at: '2026-07-16T00:00:00Z', updated_by: 1, change_summary: '{}', @@ -126,10 +126,13 @@ describe('PromptAuditView', () => { expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true) await wrapper.get('[data-test="confirm-action"]').trigger('click') expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('true') + await wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').trigger('click') + expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes('aria-checked')).toBe('true') await wrapper.get('[data-test="enabled-toggle"]').trigger('click') expect(wrapper.get('[data-test="enabled-toggle"]').attributes('aria-checked')).toBe('false') expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('false') expect(wrapper.get('[data-test="blocking-toggle"]').attributes()).toHaveProperty('disabled') + expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes()).toHaveProperty('disabled') }) it('clears plaintext token state after a successful save', async () => { @@ -175,7 +178,7 @@ describe('PromptAuditView', () => { await flushPromises() await wrapper.get('[data-test="tab-config"]').trigger('click') const switches = wrapper.findAll('[role="switch"]') - expect(switches).toHaveLength(3) + expect(switches).toHaveLength(4) expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true) expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0') expect(wrapper.html()).toContain('flex-wrap') diff --git a/frontend/src/features/prompt-audit/__tests__/components.spec.ts b/frontend/src/features/prompt-audit/__tests__/components.spec.ts index e9d679527a..aa3ead12d5 100644 --- a/frontend/src/features/prompt-audit/__tests__/components.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/components.spec.ts @@ -67,7 +67,7 @@ describe('Prompt Audit components', () => { it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => { const draft: PromptAuditDraft = { - enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', + enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: false, group_ids: [1, 99], endpoints: [endpoint()], config_version: 1, updated_at: '', updated_by: 0, change_summary: '', } diff --git a/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts b/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts index f4390466a8..06cf6b4635 100644 --- a/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts @@ -13,6 +13,7 @@ import { const config = (): PromptAuditConfig => ({ enabled: true, blocking_enabled: false, + blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', @@ -56,6 +57,12 @@ describe('Prompt Audit view model', () => { expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: true }) }) + it('includes the optional narrow blocking scope in the update payload', () => { + const draft = configToDraft(config()) + draft.blocking_latest_turn_only = true + expect(buildUpdateRequest(draft)).toMatchObject({ blocking_latest_turn_only: true }) + }) + it('tracks dirty state from the full normalized save payload', () => { const original = configToDraft(config()) const changed = configToDraft(config()) diff --git a/frontend/src/features/prompt-audit/types.ts b/frontend/src/features/prompt-audit/types.ts index ea00cb8f7e..aeeb16b96f 100644 --- a/frontend/src/features/prompt-audit/types.ts +++ b/frontend/src/features/prompt-audit/types.ts @@ -23,6 +23,7 @@ export interface PromptAuditEndpointDraft extends PromptAuditEndpoint { export interface PromptAuditConfig { enabled: boolean blocking_enabled: boolean + blocking_latest_turn_only: boolean store_pass_events: boolean effective_mode: PromptAuditMode strategy: 'priority' @@ -46,6 +47,7 @@ export interface PromptAuditUpdateRequest { expected_config_version: number enabled: boolean blocking_enabled: boolean + blocking_latest_turn_only: boolean store_pass_events: boolean strategy: 'priority' worker_count: number diff --git a/frontend/src/features/prompt-audit/viewModel.ts b/frontend/src/features/prompt-audit/viewModel.ts index f5a06760e1..2762a94b57 100644 --- a/frontend/src/features/prompt-audit/viewModel.ts +++ b/frontend/src/features/prompt-audit/viewModel.ts @@ -62,6 +62,7 @@ export function buildUpdateRequest(draft: PromptAuditDraft): PromptAuditUpdateRe expected_config_version: draft.config_version, enabled: draft.enabled, blocking_enabled: draft.enabled && draft.blocking_enabled, + blocking_latest_turn_only: draft.blocking_latest_turn_only, store_pass_events: draft.store_pass_events, strategy: 'priority', worker_count: Number(draft.worker_count), diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 483459061f..0a521bef45 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -131,6 +131,10 @@ export default { rateLimited: 'Rate Limited', overloaded: 'Overloaded', tempUnschedulable: 'Temp Unschedulable', + accountSchedulingThresholdOverride: 'Account Auto-Pause Threshold Override', + accountSchedulingThresholdOverrideHint: 'Override the platform auto-pause threshold for this account only. Disable to use platform settings.', + accountSchedulingThresholdOverrideValue: 'Account threshold percent', + accountSchedulingThresholdOverrideDisabledHint: 'Use 1-100. The account becomes temporarily unschedulable after reaching this usage percent; 100 disables it for this account.', quotaExceeded: 'Quota Exceeded', unschedulable: 'Unschedulable', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', @@ -140,7 +144,8 @@ export default { creditsExhausted: 'Credits Exhausted', creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}', overloadedUntil: 'Overloaded until {time}', - viewTempUnschedDetails: 'View temp unschedulable details' + viewTempUnschedDetails: 'View temp unschedulable details', + tempUnschedulableUntil: 'Resumes {time}' }, columns: { name: 'Name', @@ -225,7 +230,11 @@ export default { upstreamBilling: { trustWarning: 'This rate is declared by the upstream site for the current API key. Sub2API cannot verify that it matches actual charges. The upstream site or an intermediary may return forged, stale, or modified data. Verify it against bills, balance changes, and actual usage.', autoProbe: 'Automatically probe upstream declared rate', - autoProbeHint: 'Probe this account\'s upstream declared rate on the global interval when global probing is enabled.', + autoProbeHint: 'Refresh the upstream declared rate on the global interval. This switch alone does not change the account rate.', + syncRate: 'Sync upstream declared rate', + syncRateHint: 'Update the account rate after each successful probe, using the base rate excluding peak hours. Failed probes or declarations outside the allowed range leave it unchanged. Enabling this also turns on "Automatically probe upstream declared rate".', + syncRateManagedHint: 'The current rate is maintained automatically from the upstream declared base rate (excluding peak hours).', + syncedRateTooltip: 'This account rate is synchronized from the upstream declared base rate (excluding peak hours)', manualProbe: 'Probe upstream rate now', stale: 'Stale', unsupported: 'Unsupported', @@ -250,7 +259,7 @@ export default { enabled: 'On', disabled: 'Off', probeFailed: 'Failed to probe upstream rate', - noEligibleAccounts: 'Select OpenAI API key accounts', + noEligibleAccounts: 'Select API key accounts', batchLimit: 'A batch can probe at most 20 accounts', batchCompleted: 'Probed {count} account(s)', batchPartial: 'Probe partially completed: {success} succeeded, {failed} failed' @@ -300,6 +309,10 @@ export default { notice: 'Rules are evaluated in order and require both error code and keyword match.', addRule: 'Add Rule', ruleOrder: 'Rule Order', + multipleErrorTrigger: '{count} matching errors in {minutes} minutes reached the trigger threshold ({threshold}).', + multipleErrorTriggerNoWindow: '{count} matching errors reached the trigger threshold ({threshold}).', + multipleErrorCountInWindow: '{count} matching errors occurred within {minutes} minutes.', + multipleErrorCount: '{count} matching errors contributed to this block.', ruleIndex: 'Rule #{index}', errorCode: 'Error Code', errorCodePlaceholder: 'e.g. 429', @@ -388,10 +401,16 @@ export default { bulkSchedulableResultUnknown: 'Bulk scheduling result incomplete. Please retry or refresh.', bulkActions: { selected: '{count} account(s) selected', + selectedAll: 'All {count} account(s) selected', selectCurrentPage: 'Select this page', + selectAllResults: 'Select all results ({count})', + selectingAll: 'Selecting all results...', + selectAllFailed: 'Failed to load all accounts. The previous selection was kept.', clear: 'Clear selection', edit: 'Bulk Edit', delete: 'Bulk Delete', + confirmDelete: 'Delete the selected {count} account(s)? This action cannot be undone.', + deleteSuccess: 'Deleted {count} account(s)', enableScheduling: 'Enable Scheduling', disableScheduling: 'Disable Scheduling', resetStatus: 'Reset Status', @@ -414,6 +433,8 @@ export default { failed: 'Bulk update failed', noSelection: 'Please select accounts to edit', noFieldsSelected: 'Select at least one field to update', + rateSyncWarning: 'Accounts with upstream rate sync enabled cannot be changed in bulk. Disable sync in the account editor first.', + rateSyncConflict: 'Cannot change account rates: {count} target account(s) have upstream rate sync enabled.', mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.' }, bulkDeleteTitle: 'Bulk Delete Accounts', @@ -489,6 +510,9 @@ export default { oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', + flattenNamespaces: 'Flatten Codex namespace tools (compatibility)', + flattenNamespacesDesc: + 'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions... Compaction requests always flatten regardless of this switch.', longContextBilling: 'API long-context pricing', longContextBillingDesc: 'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.', @@ -583,7 +607,55 @@ export default { }, grok: { baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.', - apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.' + apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.', + // Account connectivity test modal + testMode: 'Test mode', + testModeHint: + 'Text / image / video use the selected model. Web search, TTS, STT and Realtime hit standalone endpoints (not free-form chat tools).', + testModeText: 'Text (Responses)', + testModeImage: 'Image (/images/generations)', + testModeVideo: 'Video (/videos/generations)', + testModeSearch: 'Web search (/web_search)', + testModeTTS: 'TTS (/tts)', + testModeSTT: 'STT (/stt)', + testModeRealtime: 'Realtime (WS /realtime)', + textTestMode: 'Mode: Text (Responses)', + searchTestMode: 'Mode: Web search (/web_search)', + ttsTestMode: 'Mode: TTS (/tts)', + sttTestMode: 'Mode: STT (/stt)', + realtimeTestMode: 'Mode: Realtime (WS /realtime)', + searchQueryLabel: 'Search query', + searchQueryPlaceholder: 'Example: xAI Grok', + searchQueryDefault: 'xAI Grok', + searchTestHint: + 'Standalone web_search probe (same as gateway /v1/web_search). Not a free-form chat with tools.', + ttsTextLabel: 'TTS text', + ttsTextPlaceholder: 'Example: Hello from Sub2API connectivity test.', + ttsTextDefault: 'Hello from Sub2API account connectivity test.', + ttsTestHint: 'Standalone /v1/tts with language=en; success reports audio byte size.', + sttTestHint: 'Standalone /v1/stt with a synthetic silent WAV; success means the endpoint is reachable.', + realtimeTestHint: + 'Standalone WebSocket dial to /v1/realtime (model=grok-voice-latest). Handshake success = connectivity OK; may also show the first server event.', + sendingSearchRequest: 'Sending standalone web_search request...', + sendingTTSRequest: 'Sending standalone /tts request...', + sendingSTTRequest: 'Sending standalone /stt request...', + sendingRealtimeRequest: 'Dialing standalone /realtime WebSocket...', + selectedTestMode: 'Test mode: {mode}', + imageUploadLabel: 'Source image (optional, for edits)', + videoFirstFrameLabel: 'First-frame / reference image (optional)', + imageUploadHint: + 'PNG/JPEG recommended, both sides ≥ 8 px, under ~4 MB for edits. Uploading a source image switches to /images/edits (image-to-image). Leave empty for text-to-image /images/generations.', + videoFirstFrameHint: + 'Optional first-frame / reference image for image-to-video. PNG/JPEG recommended, both sides ≥ 8 px.', + audioUploadLabel: 'Audio file (optional for STT)', + audioUploadHint: + 'Upload a real audio clip to transcribe. Without a file, a silent WAV is used for connectivity only.', + mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).', + chooseImageFile: 'Choose image', + chooseAudioFile: 'Choose audio', + uploadPreviewAlt: 'Upload preview', + fileReadFailed: 'Failed to read the selected file', + noResponseBody: 'No response body from server' }, anthropic: { apiKeyPassthrough: 'Auto passthrough (auth only)', @@ -994,6 +1066,17 @@ export default { ssoCookieLabel: 'Grok Web SSO Key', ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line', ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.', + emailPasswordAuth: 'Email + password', + emailPasswordDesc: + 'Sign in with a Grok web email and password. The server uses the password only to obtain an ephemeral SSO cookie, then converts it to Build OAuth credentials. Neither the password nor raw SSO is stored on the account.', + emailPasswordInputLabel: 'email----password', + emailPasswordPlaceholder: "user{'@'}example.com----your-password\nMultiple lines supported", + emailPasswordHint: + 'Format: email----password (password may contain -). Requires YesCaptcha keys; use a matching-region proxy when needed.', + pleaseEnterPassword: 'Please enter email----password (one per line)', + pleaseEnterSSOToken: 'Please enter an SSO token', + failedToValidateSSO: 'Failed to validate Grok SSO', + failedToAuthorizePassword: 'Grok password authorization failed', convertingSSO: 'Converting...', convertSSOAndCreate: 'Convert & Create Account', validating: 'Validating...', @@ -1257,6 +1340,9 @@ export default { reAuthorizedSuccess: 'Account re-authorized successfully', // Test Modal testAccountConnection: 'Test Account Connection', + errorPrefix: 'Error: {message}', + imagePreviewAlt: 'Test image {index}', + imageLightboxAlt: 'Image preview', account: 'Account', readyToTest: 'Ready to test. Click "Start Test" to begin...', connectingToApi: 'Connecting to API...', @@ -1280,10 +1366,22 @@ export default { imagePromptLabel: 'Image prompt', imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.', imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', - imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.', + imageTestHint: + 'Calls standalone /v1/images/generations and shows the returned image below.', imageTestMode: 'Mode: Image generation test', + videoPromptLabel: 'Video prompt', + videoPromptPlaceholder: 'Example: A red ball bouncing once on a white floor, short simple motion.', + videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.', + videoTestHint: + 'Calls standalone /v1/videos/generations, polls until done, then downloads the finished video for on-page preview.', + videoTestMode: 'Mode: Video generation test', + sendingVideoRequest: 'Sending video generation request...', imagePreview: 'Generated images:', imageReceived: 'Received test image #{count}', + audioPreview: 'Generated audio:', + audioReceived: 'Received test audio #{count}', + videoPreview: 'Generated video:', + videoReceived: 'Received test video #{count}', // Stats Modal viewStats: 'View Stats', usageStatistics: 'Usage Statistics', @@ -1331,6 +1429,12 @@ export default { grokTokens: 'Tok', grokFreeQuota24hHint: 'Estimated from local token usage over the rolling 24-hour window ({limit} limit)', grokWeeklyUsage: 'Weekly {percent}%', + grokUsed: 'Used $', + grokBalance: 'Bal $', + grokPrepaid: 'Prepaid balance', + grokMonthlyLimit: 'Monthly used / limit (USD)', + grokOverage: 'Overage onDemandUsed/onDemandCap', + grokOverageShort: 'OD $', grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.', grokRetryAfter: 'Retry after {time}', grokProbe: 'Probe', @@ -1359,7 +1463,11 @@ export default { collapseExpirations: 'Collapse reset credit expirations', expirationDetails: 'Reset credit expiration details', noCreditsAvailable: 'No reset credits available', - resetSuccess: 'Reset {windows} window(s)', + resetSuccess: 'Reset {windows} window(s); credits and account state updated', + resetCacheRefreshFailed: 'The window was reset and account state recovered, but the reset-credit count could not be read back. Query it again.', + resetAccountRecoveryFailed: 'The window was reset, but account state recovery failed. Recover the account state manually.', + resetAccountRefreshFailed: 'The window, account state, and reset-credit cache were updated, but the latest account display could not be loaded.', + refreshCachePersistFailed: 'Showing the live count, but its expiration details were unavailable, so the cached details were kept.', confirmTitle: 'Confirm Weekly Limit Reset', confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?' }, diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 96c0a6cbdf..6b26e21da9 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -143,6 +143,7 @@ export default { billingModelSourceChannelMapped: 'Bill by channel-mapped model', billingModelSourceRequested: 'Bill by requested model', billingModelSourceUpstream: 'Bill by final upstream model', + billingModelSourceResponse: 'Bill by upstream response model', billingModelSourceHint: 'Controls which model name is used for pricing lookup', selectedCount: '{count} selected', searchGroups: 'Search groups...', @@ -286,6 +287,8 @@ export default { timeoutMs: 'HTTP Timeout (ms)', retryCount: 'Retry Count', sampleRate: 'Sample Rate', + proxy: 'Proxy Server', + proxyHint: 'Send moderation requests through the selected proxy (IP Management - Proxy Servers), useful when the egress IP is not supported by OpenAI. Defaults to direct connection.', recordNonHits: 'Record Non-Hits', recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.', preHashCheck: 'Enable Pre-Hash Check', @@ -623,7 +626,9 @@ export default { quotaEndsInMinutes: 'Quota ends in {minutes}m', quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', - daysRemaining: 'days remaining', + daysRemaining: '{days} days remaining', + hoursMinutesRemaining: '{hours}h {minutes}m remaining', + minutesRemaining: '{minutes}m remaining', remainingDays: 'Remaining days', noExpiration: 'No expiration', status: { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 67887a9f45..f626b6edc6 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -144,6 +144,7 @@ export default { status: 'Status', fileName: 'File Name', size: 'Size', + parts: 'Parts', expiresAt: 'Expires At', triggeredBy: 'Triggered By', startedAt: 'Started At', @@ -168,6 +169,10 @@ export default { empty: 'No backup records', actions: { download: 'Download', + downloadParts: 'Download Parts', + downloadPartsHint: 'Download every part in order and concatenate the gzip bytes: on Linux/macOS run cat payload.part-* > backup.sql.gz; on Windows run copy /b payload.part-000001+payload.part-000002 backup.sql.gz.', + partLabel: 'Part {index}', + downloadFailed: 'Download URL is empty', restore: 'Restore', restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!', restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation', @@ -850,7 +855,7 @@ export default { rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).', maxReasoningEffort: 'Max reasoning effort', maxReasoningEffortUnlimited: 'Unlimited (follow request)', - maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', + maxReasoningEffortHint: 'Limits explicit OpenAI reasoning effort requests only. For Composite groups, it applies only to requests resolved to OpenAI. Higher values are capped; omitted effort stays omitted. The ceiling takes precedence over reasoning effort mappings.', reasoningEffortMappings: 'Reasoning effort mappings', addReasoningEffortMapping: 'Add mapping', removeReasoningEffortMapping: 'Remove mapping', @@ -985,6 +990,8 @@ export default { title: 'Video Generation Pricing', description: 'Configure Grok video generation prices in USD per second of output video. Leave empty to use the default per-second rates (grok-imagine-video: $0.05/s 480p, $0.07/s 720p; video-1.5: $0.08/s 480p, $0.14/s 720p, $0.25/s 1080p).', + modelOverridesTitle: 'Per-model video price overrides', + modelOverridesDescription: 'Each populated cell overrides the flat resolution price for that model family. Preview and legacy aliases for video-1.5 use the same family; empty cells fall back to the flat resolution price.', independentMultiplier: 'Use independent video multiplier', videoMultiplier: 'Video multiplier', modeHint: @@ -992,6 +999,20 @@ export default { finalPricePreview: 'Final per-second price preview', notConfigured: 'Not configured' }, + explicitPricing: { + title: 'Grok Search & Voice Pricing', + description: 'Optional per-group prices for web_search (per 1k calls) and Voice realtime / TTS / STT (USD). Leave empty if unused.', + searchPricePer1k: 'Search price per 1k calls (USD)', + pricePlaceholder: 'optional' + }, + voicePricing: { + title: 'Grok Voice Pricing', + description: 'Optional per-group prices for Voice realtime / TTS / STT (USD). Leave empty to leave unpriced.', + audioRealtimePerMin: 'Realtime price per minute (USD)', + audioTtsPerMillionChars: 'TTS price per million chars (USD)', + audioSttPerHour: 'STT price per hour (USD)', + pricePlaceholder: 'optional' + }, webSearchPricing: { title: 'Codex Web Search Pricing', pricePerCall: 'Price per search call (USD)', @@ -1006,6 +1027,18 @@ export default { peakMultiplier: 'Peak multiplier', multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' }, + profitControl: { + enable: 'Enable profit control', + enabledHint: 'Scheduling only admits accounts whose account multiplier ≤ the request\'s effective downstream multiplier × (1 − min margin − safety buffer). Account multipliers may be maintained manually or synchronized from probes; existing ordering, stickiness and breakers keep working among qualified accounts. Image/video scheduling is not covered yet.', + disabledHint: 'When disabled, scheduling does no profit filtering: accounts whose account multiplier exceeds the downstream multiplier can still be selected, which may produce loss-making requests.', + minMargin: 'Min gross margin (%)', + minMarginHint: 'Percent input, e.g. 30 means 30%; stored as a decimal on the backend', + safetyBuffer: 'Safety buffer (%)', + safetyBufferHint: 'Added to min margin and deducted from the downstream multiplier; defaults to 0', + marginRangeError: 'Min gross margin must be between 0 and 99.99', + bufferRangeError: 'Safety buffer must be between 0 and 99.99', + sumTooHigh: 'Min gross margin plus safety buffer must be less than 100%, otherwise every account would be excluded' + }, modelsList: { title: 'Custom /v1/models Model List', hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.', diff --git a/frontend/src/i18n/locales/en/admin/promptAudit.ts b/frontend/src/i18n/locales/en/admin/promptAudit.ts index c7263bb0c1..3206cfbbec 100644 --- a/frontend/src/i18n/locales/en/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/en/admin/promptAudit.ts @@ -55,7 +55,7 @@ export default { searchGroups: 'Search groups', noGroups: 'No matching groups', missingGroups: 'Configured IDs for groups that no longer exist', selectedCount: '{count} groups selected', scanners: 'Qwen3Guard input-risk categories', workerCount: 'Worker count', queueCapacity: 'Persistent queue capacity', strategy: 'Node strategy', strategyHint: 'Try nodes in configuration order and fail over when allowed.', }, - saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' }, + saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', blockingLatestTurnOnly: 'Only latest input and prior output', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' }, blockingConfirm: { title: 'Enable synchronous blocking?', message: 'Applicable requests wait for Guard before account selection, billing, or upstream access. Block, unavailable Guard, and invalid responses all prevent upstream access.', diff --git a/frontend/src/i18n/locales/en/admin/resources.ts b/frontend/src/i18n/locales/en/admin/resources.ts index e29a85923a..f41816665b 100644 --- a/frontend/src/i18n/locales/en/admin/resources.ts +++ b/frontend/src/i18n/locales/en/admin/resources.ts @@ -544,6 +544,10 @@ export default { billingModeImage: 'Image', billingModeVideo: 'Video', allBillingModes: 'All Billing Modes', + upstreamModelAudit: 'Upstream model audit', + allUpstreamModelAudit: 'All response model states', + upstreamModelMismatchOnly: 'Mismatched only', + upstreamModelMatchedOnly: 'Matched only', ipAddress: 'IP', clickToViewBalance: 'Click to view balance history', failedToLoadUser: 'Failed to load user info', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 5e54b22214..b20e2ebeee 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -16,12 +16,21 @@ export default { features: { channelMonitor: { title: 'Channel Monitor', - description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.', + description: 'Choose either V1 active probes or V2 passive usage monitoring. When disabled, both background jobs stop and the user entry is hidden.', configureLink: 'Configure monitors in Channel Management > Channel Monitor', enabled: 'Enable Channel Monitor', - enabledHint: 'Disabling stops background checks; existing history is preserved.', + enabledHint: 'Disabling stops both the V1 scheduler and V2 aggregation; existing config and history are kept.', + mode: 'Monitor mode', + modeHint: 'Default is V1 (active probes). Switch to V2 only when you want passive aggregation; only one implementation can be active at a time.', + modeV2: 'V2 passive monitoring', + modeV1: 'V1 active probes', + modeV2Hint: 'Opt-in: aggregates health metrics from real gateway traffic without upstream probe traffic. V1 probes stop while V2 is active.', + modeV1Hint: 'Default: runs scheduled upstream health checks for configured channel monitors (probe traffic).', defaultInterval: 'Default check interval (seconds)', - defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.', + defaultIntervalHint: 'V1 only: default interval for new monitors (overridable per monitor). Range 15 – 3600 seconds.', + hideThroughput: 'Hide throughput rates from users (RPM / TPM)', + hideThroughputHint: + 'When on, the user Channel Monitor page and user APIs omit RPM and TPM so fleet volume cannot be reverse-estimated from rates × window. Admins still see full metrics. Error rates, latency, and cache rates remain visible.', }, availableChannels: { title: 'Available Channels', @@ -119,9 +128,12 @@ export default { emailVerificationHint: 'Require email verification for new registrations', emailSuffixWhitelist: 'Email Domain Whitelist', emailSuffixWhitelistHint: - "Only email addresses from the specified domains can register (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", + "Only email addresses from the specified domains can register; leave empty for no restriction (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.', + emailDomainQuota: 'Non-allowlist Domain Quota', + emailDomainQuotaHint: + 'When enabled and the allowlist is not empty, every other registrable domain can register one account. When disabled, non-allowlist domains are rejected. Has no effect while the allowlist is empty', promoCode: 'Promo Code', promoCodeHint: 'Allow users to use promo codes during registration', invitationCode: 'Invitation Code Registration', @@ -144,6 +156,7 @@ export default { passkeyRPID: 'RP ID', passkeyOrigins: 'Allowed HTTPS origins', passkeyValueNotConfigured: 'Not configured', + passkeyDeploymentHint: 'Ask the server operator to set webauthn.enabled to true, configure webauthn.rp_id (domain only) and webauthn.rp_origins (full HTTPS origins), then restart the service.', stepUp: 'Step-up 2FA for Sensitive Operations', stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.', stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.', @@ -182,6 +195,58 @@ export default { secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, + captcha: { + title: 'CAPTCHA', + description: 'Bot protection for login and registration', + enable: 'Enable CAPTCHA', + enableHint: 'Require human verification on login, registration and related flows', + provider: 'Provider', + providerTurnstile: 'Cloudflare Turnstile', + providerTencent: 'Tencent Captcha', + providerAliyun: 'Aliyun Captcha 2.0' + }, + tencentCaptcha: { + title: 'Tencent Captcha', + description: 'Slider captcha protection for login, registration, and third-party account creation', + enable: 'Enable Tencent Captcha', + enableHint: 'Use Tencent slider captcha in every existing Turnstile flow', + keepExisting: 'Leave empty to keep current value', + configured: 'Configured. Leave empty to keep it.', + required: 'Required before enabling.', + mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.', + region: 'Service site', + regionCn: 'Chinese mainland', + regionIntl: 'International', + regionHint: 'Selects the SDK script and the server-side verification endpoint. It must match the site that issued your CaptchaAppId; international apps are created in the tencentcloud.com console.', + appCredentialsTitle: 'Captcha application credentials', + appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.', + cloudCredentialsTitle: 'Cloud API credentials', + cloudCredentialsHint: 'SecretId and SecretKey authorize server-side DescribeCaptchaResult requests.', + appId: 'CaptchaAppId', + appSecretKey: 'AppSecretKey', + cloudSecretId: 'Tencent Cloud SecretId', + cloudSecretKey: 'Tencent Cloud SecretKey', + camPermissionHint: 'Create a CAM sub-user with QcloudCaptchaFullAccess instead of using permanent root-account credentials.', + aidEncryptedHint: 'aidEncrypted is not supported yet. Keep CaptchaAppId mandatory verification disabled in the Captcha console.', + openCaptchaConsole: 'Open Captcha console', + createCloudKeys: 'Create SecretId / SecretKey', + openWebDocs: 'View Web integration guide' + }, + aliyunCaptcha: { + accessKeyId: 'AccessKey ID', + accessKeyIdHint: 'Alibaba Cloud AccessKey ID used for server-side verification; a captcha-only RAM user is recommended', + accessKeySecret: 'AccessKey Secret', + accessKeySecretHint: 'Server-side verification secret (keep this secret)', + accessKeySecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + sceneId: 'Scene ID', + sceneIdHint: 'Create a verification scene in the Alibaba Cloud Captcha console; the captcha type (invisible/slider/puzzle) is configured per scene there', + prefix: 'Prefix', + prefixHint: 'Found in the instance information on the console overview page', + region: 'Region', + regionCn: 'Mainland China', + regionSgp: 'Singapore', + regionHint: 'Determines the frontend script region and the server endpoint; must match your captcha instance region' + }, apiKeyAcl: { title: 'API Key IP Access Control', description: @@ -356,11 +421,16 @@ export default { title: 'Gateway Scheduling Settings', description: 'Control API Key scheduling behavior', allowUngroupedKey: 'Allow Ungrouped Key Scheduling', - allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.' + allowUngroupedKeyHint: 'When disabled, API Keys not assigned to any group cannot make requests (403 Forbidden). Keep disabled to ensure all Keys belong to a specific group.', + accountSchedulingThresholdsTitle: 'Platform Account Auto-Pause Thresholds', + accountSchedulingThresholdsDescription: 'When an account\'s current native usage window (OpenAI Codex/Anthropic session, or Grok request/token utilization) reaches this percent, Sub2API temporarily removes it from scheduling until the window resets. Use 100 to disable.', + accountSchedulingThresholdsGlobalHint: 'System-wide default for every account on that platform. Individual accounts can still override this in the account editor.', + accountSchedulingThresholdsDisabledHint: '100 disables platform auto-pause. Values 1–99 pause scheduling once utilization reaches that percent.', + accountSchedulingThresholdsRangeHint: 'Integer 1–100 (percent). OpenAI/Anthropic/Grok only.' }, upstreamBillingProbe: { title: 'Upstream Rate Auto Detection', - description: 'Periodically retrieve billing rates declared by upstream Sub2API sites connected to OpenAI API keys.', + description: 'Periodically retrieve rates declared by upstream Sub2API sites. Account rates change only when the separate sync switch is enabled.', enabled: 'Enable global auto detection', enabledHint: 'When enabled, scheduled detection runs only for accounts that also enable automatic detection. Disabling stops all scheduled detection; manual detection remains available.', intervalMinutes: 'Detection interval (minutes)', @@ -383,6 +453,17 @@ export default { gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', + grokDefaultTextModel: 'Default Grok text model', + grokDefaultTextModelHint: 'Used for empty model values and, only when the switch is enabled, requests from other client model namespaces. Custom Grok model IDs are accepted.', + grokCrossClientMap: 'Map other clients to Grok', + grokCrossClientMapHint: 'Disabled by default. When enabled, GPT, Codex, o-series, and Claude model IDs are routed to the default Grok text model above.', + grokDefaultBaseURLMode: 'Default Grok upstream', + grokDefaultBaseURLModeHint: 'Used only when a Grok account has no explicit base URL. Media and voice endpoints continue to use their official API hosts.', + grokBaseURLModeCLI: 'CLI chat proxy', + grokBaseURLModeAPI: 'Public API', + grokBaseURLModeUSEast1: 'Regional API (us-east-1)', + grokBaseURLModeUSWest2: 'Regional API (us-west-2)', + grokBaseURLModeEUWest1: 'Regional API (eu-west-1)', fingerprintUnification: 'Fingerprint Unification', fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', metadataPassthrough: 'Metadata Passthrough', @@ -422,8 +503,14 @@ export default { antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.', openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.', + openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)', + openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests, for customizing the OS / arch / terminal fingerprint. Leave empty to build the standard codex-tui identity from the version below (recommended). If set, both the leading and trailing version declarations are synchronized to the version below, so the UA never stays pinned to the release entered here — under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.', + openaiCodexClientVersion: 'Codex client version', + openaiCodexClientVersionPlaceholder: 'Leave empty to follow auto-sync', + openaiCodexClientVersionHint: 'The Codex client version this gateway declares upstream, shared by the User-Agent and the version header. Leave empty to use the auto-synced latest stable release; setting a value pins it and stops following auto-sync.', + openaiCodexVersionAutoSync: 'Auto-sync Codex version', + openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.', + openaiCodexVersionSyncedValue: 'Currently synced: {version}', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", codexHardeningDesc: @@ -548,6 +635,8 @@ export default { homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.', homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.', homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.', + compactHome: 'Compact Home Page', + compactHomeHint: 'Show a restrained site identity page when no custom home page content is set.', hideCcsImportButton: 'Hide CCS Import Button', hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' }, diff --git a/frontend/src/i18n/locales/en/channelMonitorV2.ts b/frontend/src/i18n/locales/en/channelMonitorV2.ts new file mode 100644 index 0000000000..1d94a154dc --- /dev/null +++ b/frontend/src/i18n/locales/en/channelMonitorV2.ts @@ -0,0 +1,147 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: 'Channel Monitor', + updating: 'Updating data', + updatedTo: 'Updated to {time}', + partialCoverage: 'Partial historical coverage', + bootstrap: { + title: 'Building historical monitor data', + description: + 'On first enable, passive aggregation silently fills the 90m, 24h, 7d, and 30d windows in the background. All ranges become complete once this finishes.', + progress: '{percent}% complete', + working: 'Aggregating in the background…', + }, + timeRange: 'Time range', + clearFilters: 'Reset', + refreshingFilters: 'Filters changed; refreshing matrix, trend, and details…', + switchingData: 'Switching filtered data…', + summaryAria: 'Selected range summary', + loadFailed: 'Failed to load channel monitor', + detailLoadFailed: 'Failed to load channel monitor details', + otherModels: 'Other models', + ignored: 'Ignored', + currentUser: 'Current user', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: 'Platform', allPlatforms: 'All', group: 'Group', allGroups: 'All', model: 'Model', allModels: 'All', + empty: 'No options', selectedCount: '{count}', labelValue: '{label}: {value}' + }, + groupBy: { + label: 'Group by', platform: 'Platform', platformGroup: 'Platform / Group', platformModel: 'Platform / Model', platformGroupModel: 'Platform / Group / Model' + }, + trendView: { label: 'Trend view', pulse: 'Pulse matrix', line: 'Line chart' }, + healthMode: { label: 'Health display', overall: 'Overall', success: 'Error rate', ttft: 'First token', cache: 'Cache rate' }, + tabs: { aria: 'Detail dimension', models: 'Models', errors: 'Error reasons', users: 'User ranking' }, + metrics: { + rpm: 'RPM', + tpm: 'TPM', + tps: 'Tokens/s', + rpmDetail: 'Requests per minute', + tpmDetail: 'Tokens per minute', + tpsDetail: 'Derived as TPM ÷ 60', + errorRate: 'Error rate', + ttft: 'First token', + ttftP50: 'First token P50', + durationP50: 'Duration P50', + cacheRate: 'Cache rate', + cacheDetail: 'Read cache share', + successRate: 'Success rate', + successRateValue: 'Success rate {value}', + errorRateValue: 'Error rate {value}', + rpmValue: 'RPM {value}', + tpmValue: 'TPM {value}', + tpsValue: 'Tokens/s {value}', + ttftValue: 'First token {value}', + durationValue: 'Duration {value}', + cacheRateValue: 'Cache rate {value}', + }, + table: { platformModel: 'Platform / Model', rank: 'Rank', user: 'User' }, + empty: { title: 'No data to display', description: 'Try changing the time range or filters' }, + bucket: { minutes: '{count}-minute buckets', hours: '{count}-hour buckets', days: '{count}-day buckets' }, + matrix: { + title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Scroll over blocks to zoom in (narrower range, wider blocks)', wheelZoomX: 'Scroll over blocks to zoom in (narrower range, wider blocks)', dimension: 'Channel dimension', emptyTitle: 'No matrix data for the selected window', legendAria: 'Health score legend', bad: 'Bad', good: 'Good', healthyLegend: 'Healthy (≥80)', warningLegend: 'Watch (50–79)', criticalLegend: 'Critical (<50)', unknownLegend: 'No traffic / insufficient samples', noTraffic: 'No traffic in this interval', noTrafficAt: '{time} · no traffic', scoreLine: 'Health score {score}', resetZoom: 'Reset zoom' + }, + chart: { + title: 'Availability trend', description: 'Smoothed trend: error rate · first token P50 · cache rate', emptyTitle: 'No trend data for the selected window', errorLegend: 'Error rate (left axis %)', cacheLegend: 'Cache rate (left axis %)', ttftLegend: 'First token P50 (right axis)', errorDataset: 'Error rate trend %', cacheDataset: 'Cache rate trend %', ttftDataset: 'First token trend P50 (ms)', percentAxis: 'Rate %', resetZoom: 'Reset zoom' + }, + errorDetail: { http: 'HTTP {code}', upstream: 'Upstream {code}', noMessage: 'No error message', empty: 'Category rates only (sample messages are admin-only)' }, + errorCategories: { + content_policy: 'Content policy', authentication: 'Authentication', context_limit: 'Context limit', invalid_request: 'Invalid request', model_unsupported: 'Unsupported model', group_access: 'Group access', quota_or_balance: 'Quota or balance', account_pool_unavailable: 'Account pool unavailable', rate_or_capacity: 'Rate or capacity', timeout: 'Timeout', transport_or_stream: 'Transport or stream', upstream_forbidden: 'Upstream forbidden', not_found: 'Not found', client_cancelled: 'Client cancelled', upstream_5xx: 'Upstream 5xx', internal: 'Internal', other: 'Other' + }, + rank: { + gold: 'Rank 1 gold', + silver: 'Rank 2 silver', + bronze: 'Rank 3 bronze', + place: 'Rank {n}', + unranked: 'Unranked', + }, + settings: { + title: 'V2 data monitor config', + description: + 'Configure passive usage aggregation dimensions (platform / model / group) and refresh cadence. Health colors and details on the user /monitor page show rates, RPM, and TPM — not absolute request volume.', + save: 'Save', + loading: 'Loading…', + loadFailed: 'Failed to load V2 config', + saveSuccess: 'V2 monitor config saved', + saveFailed: 'Failed to save V2 config', + modeBanner: + 'System mode is currently {mode}. V2 minute aggregation will not run; this config can be prepared now and takes effect after switching to {modeV2}. Change mode under System Settings → Feature switches.', + modeClosed: 'Channel monitor disabled', + modeV1: 'V1 active probes', + modeV2: 'V2 passive monitoring', + enableTitle: 'Enable V2 aggregation', + enableHint: + 'Applies when system mode is V2. Turning this off only stops this config’s aggregation; the system mode switch remains under Feature switches.', + refreshTitle: 'Aggregation interval', + refreshHint: 'Affects matrix time granularity and refresh cadence', + refreshAria: 'Aggregation interval', + platformsTitle: 'Platforms and models', + platformsHint: + 'Leave empty = show all real model names; when filled, only listed models get their own rows and the rest roll into “Other”', + modelsPlaceholder: 'Empty = all real models; or list popular models (rest → Other)', + badgeAllModels: 'All models', + badgeOther: '+ Other', + groupsTitle: 'Monitored groups', + groupsSelected: '{count} groups selected', + groupsAll: 'All groups', + groupsEmpty: 'No groups available', + errorsTitle: 'Error categories and ignores', + errorsHint: + 'Checked “ignore” categories are excluded from error rate and health score, but still appear greyed in the error breakdown. Unmatched errors roll into “Other”.', + ignoredSummary: 'Ignored {ignored} categories · counted in error rate {counted} categories', + healthTitle: 'Health thresholds', + healthHint: + 'Controls user-facing color bands and overall score. Defaults are tolerant so small error rates or low cache do not immediately show as unhealthy.', + fields: { + minimumSample: 'Minimum samples', + warningError: 'Error rate watch %', + criticalError: 'Error rate critical %', + targetTtft: 'TTFT target ms', + warningTtft: 'TTFT watch ms', + criticalTtft: 'TTFT critical ms', + warningCache: 'Cache rate watch %', + criticalCache: 'Cache rate critical %', + }, + namedModelsEmpty: 'Platform model lists are empty: every real model name will be shown (not folded into “Other”).', + namedModelsCount: 'Showing {count} named model dimensions; unlisted models fold into per-platform “Other”.', + userContractTitle: 'User-facing display contract', + userContract: { + health: 'Health color weights: error rate 60% + first-token P50 20% + cache rate 20% (thresholds configurable above)', + trend: 'Trend can switch between pulse matrix and line chart (error · cache · first token)', + latency: 'Latency shows AVG · P50 · P90; absolute request / error counts are not shown', + models: 'Empty model lists show real names and never dump everything into “Other”', + }, + }, + admin: { + descriptionV1: + 'System mode is V1 active probes: manage probe monitors and run checks now; V2 aggregation does not run.', + descriptionV2: + 'System mode is V2 passive monitoring: configure aggregation dimensions; V1 active probes do not run.', + tabAria: 'Monitor management', + tabV2: 'V2 data monitor config', + tabV1Active: 'V1 active probes', + tabV1History: 'V1 history (probes not active in current mode)', + }, + }, +} diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 16c797e612..a0190ca0b1 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -81,6 +81,13 @@ export default { view: 'View', settings: 'Settings', chooseFile: 'Choose File', + upload: 'Upload', + remove: 'Remove', + noFileSelected: 'No file selected', + selectedFile: 'Selected: {name}', + fileReadFailed: 'Failed to read file', + selectImageFile: 'Please select an image file', + fileTooLargeKb: 'File too large ({size} KB), max {max} KB', copy: 'Copy', notAvailable: 'N/A', now: 'Now', @@ -234,6 +241,8 @@ export default { USER_NOT_ACTIVE: 'Account has been disabled.', }, registrationFailed: 'Registration failed. Please try again.', + emailDomainRegistrationLimit: + 'This email domain cannot register another account. Please use a mainstream email, or contact support to add your enterprise domain to the allowlist.', emailSuffixNotAllowed: 'This email domain is not allowed for registration.', emailSuffixNotAllowedWithAllowed: 'This email domain is not allowed. Allowed domains: {suffixes}', @@ -243,6 +252,10 @@ export default { reloginRequired: 'Session expired. Please log in again.', turnstileExpired: 'Verification expired, please try again', turnstileFailed: 'Verification failed, please try again', + captchaVerified: 'Verification completed', + captchaLoading: 'Loading verification…', + captchaClickToVerify: 'Click to complete verification', + captchaVerifying: 'Verifying…', completeVerification: 'Please complete the verification', verifyYourEmail: 'Verify Your Email', sessionExpired: 'Session expired', diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index f07f98a122..4204a9d679 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -169,16 +169,24 @@ export default { note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', }, grok: { - description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.', + description: + 'Configure Grok CLI, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group. Text models use Responses; image/video use Imagine model IDs on media endpoints.', claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.', codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.', - configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.', - codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.', - note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.', - noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.', - claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.', - codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.', - codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.', + configTomlHint: + 'Official path: ~/.grok/config.toml (or $GROK_HOME). Fill [endpoints] (models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url), [auth] preferred_method=api_key, [models], [session], and [features] image/video overrides. Prefer env_key over api_key; every text model needs api_backend=responses. Back up before merge, then run grok inspect.', + codexConfigTomlHint: + 'Official Codex: wire_api = "responses" only; prefer env_key over experimental_bearer_token; supports_websockets = false for non-OpenAI gateways (Sub2API can still accept client WS and bridge to HTTP/SSE). Back up ~/.codex/config.toml before merge.', + note: + 'Export GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml (endpoints/auth/models/session/features) as ~/.grok/config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).', + noteWindows: + 'Set GROK_MODELS_BASE_URL and XAI_API_KEY, save the full config.toml as %USERPROFILE%\\.grok\\config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).', + claudeNote: + 'Choose one method: terminal env for this session, or ~/.claude/settings.json for persistence. Do not commit files that contain your API key.', + codexNote: + 'Export SUB2API_API_KEY, save config.toml under ~/.codex (mkdir -p ~/.codex). Prefer env_key auth; do not commit secrets.', + codexNoteWindows: + 'Set $env:SUB2API_API_KEY, save config.toml under %USERPROFILE%\\.codex. Prefer env_key auth; do not commit secrets.', }, opencode: { title: 'OpenCode Example', @@ -295,6 +303,11 @@ export default { model: 'Model', requestedModel: 'Requested', upstreamModel: 'Upstream', + sentUpstreamModel: 'Sent upstream', + upstreamResponseModel: 'Upstream response', + upstreamModelMismatch: 'Response model mismatch', + modelVariant: 'Possible version variant', + modelMismatch: 'Different model', reasoningEffort: 'Reasoning Effort', endpoint: 'Endpoint', endpointDistribution: 'Endpoint Distribution', diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index a855df543a..ac2867bfda 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 98d22a453a..6d111c7c18 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -178,7 +178,11 @@ export default { upstreamBilling: { trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。', autoProbe: '自动探测上游声明倍率', - autoProbeHint: '启用后按全局探测周期查询此账号的上游声明倍率;全局探测关闭时不会执行。', + autoProbeHint: '启用后按全局周期刷新上游声明倍率;此开关本身不会修改账号倍率。', + syncRate: '同步上游声明倍率', + syncRateHint: '成功探测后自动更新账号倍率,同步的是不含高峰的基准倍率;探测失败或声明超出允许范围时保持不变。开启本项会同时打开“自动探测上游声明倍率”。', + syncRateManagedHint: '当前倍率由上游声明的基准倍率(不含高峰)自动维护。', + syncedRateTooltip: '该账号倍率由上游声明的基准倍率(不含高峰)自动同步', manualProbe: '立即探测上游倍率', stale: '已过期', unsupported: '不支持', @@ -203,7 +207,7 @@ export default { enabled: '打开', disabled: '关闭', probeFailed: '探测上游倍率失败', - noEligibleAccounts: '请选择 OpenAI API Key 账号', + noEligibleAccounts: '请选择 API Key 账号', batchLimit: '每次最多探测 20 个账号', batchCompleted: '已完成 {count} 个账号的倍率探测', batchPartial: '倍率探测部分完成:成功 {success} 个,失败 {failed} 个' @@ -330,6 +334,10 @@ export default { rateLimited: '限流中', overloaded: '过载中', tempUnschedulable: '临时不可调度', + accountSchedulingThresholdOverride: '账号自动停调阈值覆盖', + accountSchedulingThresholdOverrideHint: '仅对当前账号覆盖平台级自动停调阈值;关闭后使用平台设置。', + accountSchedulingThresholdOverrideValue: '账号阈值百分比', + accountSchedulingThresholdOverrideDisabledHint: '1-100,达到该用量百分比后临时不可调度;100 表示禁用当前账号自动停调。', quotaExceeded: '配额超限', unschedulable: '不可调度', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', @@ -339,7 +347,8 @@ export default { creditsExhausted: '积分已用尽', creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复', overloadedUntil: '负载过重,重置时间:{time}', - viewTempUnschedDetails: '查看临时不可调度详情' + viewTempUnschedDetails: '查看临时不可调度详情', + tempUnschedulableUntil: '预计 {time} 恢复' }, tempUnschedulable: { title: '临时不可调度', @@ -348,6 +357,10 @@ export default { notice: '规则按顺序匹配,需同时满足错误码与关键词。', addRule: '添加规则', ruleOrder: '规则序号', + multipleErrorTrigger: '{minutes} 分钟内累计 {count} 次匹配错误,达到触发阈值({threshold})。', + multipleErrorTriggerNoWindow: '累计 {count} 次匹配错误,达到触发阈值({threshold})。', + multipleErrorCountInWindow: '{minutes} 分钟内累计发生 {count} 次匹配错误。', + multipleErrorCount: '本次不可调度由累计 {count} 次匹配错误触发。', ruleIndex: '规则 #{index}', errorCode: '错误码', errorCodePlaceholder: '例如 429', @@ -397,6 +410,12 @@ export default { grokTokens: 'Token', grokFreeQuota24hHint: '按 sub2api 近 24 小时本地 Token 用量估算(上限 {limit})', grokWeeklyUsage: '周额度已用 {percent}%', + grokUsed: '已用 $', + grokBalance: '余额 $', + grokPrepaid: '预付余额', + grokMonthlyLimit: '月度已用/上限(USD)', + grokOverage: '超额 onDemandUsed/onDemandCap', + grokOverageShort: '超额 $', grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。', grokRetryAfter: '{time} 后重试', grokProbe: '探测', @@ -425,7 +444,11 @@ export default { collapseExpirations: '收起重置次数到期时间', expirationDetails: '重置次数到期明细', noCreditsAvailable: '没有可用的重置次数', - resetSuccess: '已重置 {windows} 个窗口', + resetSuccess: '已重置 {windows} 个窗口,次数和账号状态已更新', + resetCacheRefreshFailed: '窗口已重置、账号状态已恢复,但重置次数未能回读,请重新查询次数。', + resetAccountRecoveryFailed: '窗口已重置,但账号状态恢复失败,请手动恢复账号状态。', + resetAccountRefreshFailed: '窗口、账号状态和重置次数缓存已更新,但无法加载最新账号显示。', + refreshCachePersistFailed: '已显示实时次数,但到期明细获取失败,仍保留原有缓存明细。', confirmTitle: '确认重置周限', confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?' }, @@ -457,10 +480,16 @@ export default { bulkSchedulableResultUnknown: '批量调度结果不完整,请稍后重试或刷新列表', bulkActions: { selected: '已选择 {count} 个账号', + selectedAll: '已选择全部 {count} 个账号', selectCurrentPage: '本页全选', + selectAllResults: '全选所有结果({count})', + selectingAll: '正在选择全部结果...', + selectAllFailed: '获取全部账号失败,原有选择未改变', clear: '清除选择', edit: '批量编辑账号', delete: '批量删除', + confirmDelete: '确认删除选中的 {count} 个账号吗?此操作不可恢复。', + deleteSuccess: '已成功删除 {count} 个账号', enableScheduling: '批量启用调度', disableScheduling: '批量停止调度', resetStatus: '批量重置状态', @@ -482,6 +511,8 @@ export default { failed: '批量更新失败', noSelection: '请选择要编辑的账号', noFieldsSelected: '请至少选择一个要更新的字段', + rateSyncWarning: '已开启上游倍率同步的账号不能批量手工修改倍率,请先在账号编辑页关闭同步。', + rateSyncConflict: '无法修改账号倍率:{count} 个目标账号已开启上游倍率同步。', mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。' }, bulkDeleteTitle: '批量删除账号', @@ -556,6 +587,9 @@ export default { oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', + flattenNamespaces: '摊平 Codex namespace 工具(兼容)', + flattenNamespacesDesc: + '默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。', longContextBilling: 'API 长上下文计费', longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。', responsesWebsocketsV2: 'Responses WebSocket v2', @@ -643,7 +677,54 @@ export default { }, grok: { baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。', - apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。' + apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。', + // 账号连通性测试弹窗 + testMode: '测试模式', + testModeHint: + '文本 / 图片 / 视频使用所选模型。网页搜索、TTS、STT、Realtime 走独立接口探测(不是对话里的 tools)。', + testModeText: '文本(Responses)', + testModeImage: '图片(/images/generations)', + testModeVideo: '视频(/videos/generations)', + testModeSearch: '网页搜索(/web_search)', + testModeTTS: '语音合成 TTS(/tts)', + testModeSTT: '语音识别 STT(/stt)', + testModeRealtime: '实时语音 Realtime(WS /realtime)', + textTestMode: '模式:文本(Responses)', + searchTestMode: '模式:网页搜索(/web_search)', + ttsTestMode: '模式:TTS(/tts)', + sttTestMode: '模式:STT(/stt)', + realtimeTestMode: '模式:Realtime(WS /realtime)', + searchQueryLabel: '搜索关键词', + searchQueryPlaceholder: '例如:xAI Grok', + searchQueryDefault: 'xAI Grok', + searchTestHint: + '独立网页搜索探测(与网关 /v1/web_search 语义一致),不是带 tools 的自由对话。', + ttsTextLabel: 'TTS 文本', + ttsTextPlaceholder: '例如:Hello from Sub2API connectivity test.', + ttsTextDefault: 'Hello from Sub2API account connectivity test.', + ttsTestHint: '独立调用 /v1/tts(language=en);成功时显示音频字节数。', + sttTestHint: '独立调用 /v1/stt,使用合成静音 WAV;成功表示接口可达。', + realtimeTestHint: + '独立 WebSocket 拨号 /v1/realtime(model=grok-voice-latest)。握手成功即连通;若有首包服务端事件会一并显示。', + sendingSearchRequest: '正在发送独立 web_search 请求...', + sendingTTSRequest: '正在发送独立 /tts 请求...', + sendingSTTRequest: '正在发送独立 /stt 请求...', + sendingRealtimeRequest: '正在拨号独立 /realtime WebSocket...', + selectedTestMode: '测试模式:{mode}', + imageUploadLabel: '源图片(可选,图生图/编辑)', + videoFirstFrameLabel: '首帧 / 参考图(可选)', + imageUploadHint: + '建议 PNG/JPEG,宽高均 ≥ 8 像素,编辑建议小于约 4MB。上传源图会走 /images/edits(图生图);不上传则走 /images/generations 文生图。', + videoFirstFrameHint: + '可选首帧/参考图用于图生视频。建议 PNG/JPEG,宽高均 ≥ 8 像素。', + audioUploadLabel: '音频文件(STT 可选)', + audioUploadHint: '上传真实音频做转写;不上传则用静音 WAV 仅测连通。', + mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。', + chooseImageFile: '选择图片', + chooseAudioFile: '选择音频', + uploadPreviewAlt: '上传预览', + fileReadFailed: '读取所选文件失败', + noResponseBody: '服务器未返回响应体' }, anthropic: { apiKeyPassthrough: '自动透传(仅替换认证)', @@ -1043,6 +1124,16 @@ export default { ssoCookieLabel: 'Grok Web SSO Key', ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个', ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。', + emailPasswordAuth: '邮箱密码登录', + emailPasswordDesc: + '使用 Grok 网页邮箱与密码登录。服务端仅用密码换取临时 SSO 再转 Build OAuth;密码与 raw SSO 均不会写入账号凭据。', + emailPasswordInputLabel: '邮箱----密码', + emailPasswordPlaceholder: "user{'@'}example.com----your-password\n支持多个,每行一组", + emailPasswordHint: '格式:email----password(密码可含 -)。需要配置 YesCaptcha 密钥;建议搭配代理。', + pleaseEnterPassword: '请输入 email----password(每行一组)', + pleaseEnterSSOToken: '请输入 SSO Token', + failedToValidateSSO: '校验 Grok SSO 失败', + failedToAuthorizePassword: 'Grok 密码授权失败', convertingSSO: '转换中...', convertSSOAndCreate: '转换并创建账号', validating: '验证中...', @@ -1301,6 +1392,9 @@ export default { reAuthorizedSuccess: '账号重新授权成功', // Test Modal testAccountConnection: '测试账号连接', + errorPrefix: '错误:{message}', + imagePreviewAlt: '测试图片 {index}', + imageLightboxAlt: '图片预览', account: '账号', readyToTest: '准备测试。点击"开始测试"按钮开始...', connectingToApi: '连接 API 中...', @@ -1322,10 +1416,22 @@ export default { imagePromptLabel: '生图提示词', imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。', imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', - imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。', + imageTestHint: + '调用独立 /v1/images/generations 生图,并在下方预览返回图片。', imageTestMode: '模式:生图测试', + videoPromptLabel: '视频提示词', + videoPromptPlaceholder: '例如:一只红球在白地板上弹跳一次,动作简短。', + videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.', + videoTestHint: + '调用独立 /v1/videos/generations,轮询至完成后下载成品视频并在页面上预览。', + videoTestMode: '模式:视频生成测试', + sendingVideoRequest: '正在发送视频生成测试请求...', imagePreview: '生成结果:', imageReceived: '已收到第 {count} 张测试图片', + audioPreview: '生成音频:', + audioReceived: '已收到第 {count} 段测试音频', + videoPreview: '生成视频:', + videoReceived: '已收到第 {count} 段测试视频', // Stats Modal viewStats: '查看统计', usageStatistics: '使用统计', diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index cdfb185c8a..3da00a3120 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -143,6 +143,7 @@ export default { billingModelSourceChannelMapped: '以渠道映射后的模型计费', billingModelSourceRequested: '以请求模型计费', billingModelSourceUpstream: '以最终模型计费', + billingModelSourceResponse: '按上游响应模型计费', billingModelSourceHint: '控制使用哪个模型名称进行定价查找', selectedCount: '已选 {count} 个', searchGroups: '搜索分组...', @@ -286,6 +287,8 @@ export default { timeoutMs: 'HTTP 超时 (ms)', retryCount: '失败重试次数', sampleRate: '采样率', + proxy: '代理服务器', + proxyHint: '审计请求经指定代理(IP管理-代理服务器)发出,适用于出口 IP 不受 OpenAI 支持的部署;默认直连。', recordNonHits: '记录未命中输入', recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。', preHashCheck: '启用前置哈希比对', @@ -623,7 +626,9 @@ export default { quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', - daysRemaining: '天剩余', + daysRemaining: '剩余 {days} 天', + hoursMinutesRemaining: '剩余 {hours} 小时 {minutes} 分钟', + minutesRemaining: '剩余 {minutes} 分钟', remainingDays: '剩余天数', noExpiration: '无过期时间', status: { diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 112577b894..c4ad9a542b 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -144,6 +144,7 @@ export default { status: '状态', fileName: '文件名', size: '大小', + parts: '分卷数', expiresAt: '过期时间', triggeredBy: '触发方式', startedAt: '开始时间', @@ -168,6 +169,10 @@ export default { empty: '暂无备份记录', actions: { download: '下载', + downloadParts: '下载分卷', + downloadPartsHint: '请按顺序下载全部分卷后拼接 gzip 字节流:Linux/macOS 使用 cat payload.part-* > backup.sql.gz;Windows 使用 copy /b payload.part-000001+payload.part-000002 backup.sql.gz。', + partLabel: '第 {index} 卷', + downloadFailed: '下载地址为空', restore: '恢复', restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!', restorePasswordPrompt: '请输入管理员密码以确认恢复操作', @@ -832,7 +837,7 @@ export default { rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)', maxReasoningEffort: '推理强度上限', maxReasoningEffortUnlimited: '不限制(跟随请求)', - maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', + maxReasoningEffortHint: '仅限制客户端主动请求的 OpenAI reasoning effort;Composite 分组仅对解析到 OpenAI 的请求生效。超过上限时自动降档,不会为缺省请求主动开启推理。上限优先级高于推理强度映射。', reasoningEffortMappings: '推理强度映射', addReasoningEffortMapping: '添加映射', removeReasoningEffortMapping: '删除映射', @@ -982,6 +987,8 @@ export default { title: '视频生成计费', description: '配置 Grok 视频生成的每秒单价(USD/秒),留空则使用默认每秒价(grok-imagine-video:480p $0.05/s、720p $0.07/s;video-1.5:480p $0.08/s、720p $0.14/s、1080p $0.25/s)', + modelOverridesTitle: '按模型覆盖视频价格', + modelOverridesDescription: '已填写的单元格会覆盖该模型族的平面分辨率价格。video-1.5 的 preview 与 legacy 别名共用同一模型族;留空则回退到平面分辨率价格。', independentMultiplier: '视频倍率独立', videoMultiplier: '视频独立倍率', modeHint: @@ -989,6 +996,20 @@ export default { finalPricePreview: '最终每秒价格预览', notConfigured: '未配置' }, + explicitPricing: { + title: 'Grok 搜索与 Voice 定价', + description: '分组级 web_search(每千次)与 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。', + searchPricePer1k: '搜索每千次价格(USD)', + pricePlaceholder: '可选' + }, + voicePricing: { + title: 'Grok Voice 定价', + description: '分组级 Voice realtime / TTS / STT 单价(USD)。留空表示未配置。', + audioRealtimePerMin: 'Realtime 每分钟价格(USD)', + audioTtsPerMillionChars: 'TTS 每百万字符价格(USD)', + audioSttPerHour: 'STT 每小时价格(USD)', + pricePlaceholder: '可选' + }, webSearchPricing: { title: 'Codex 网页搜索计费', pricePerCall: '搜索单次价格(USD/次)', @@ -1003,6 +1024,18 @@ export default { peakMultiplier: '高峰倍率', multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' }, + profitControl: { + enable: '启用利润控制', + enabledHint: '调度时仅允许"账号倍率 ≤ 请求实际下游倍率 ×(1 − 最低毛利率 − 安全缓冲)"的账号进入候选池;账号倍率可手工维护或由探测同步,既有排序、粘性与熔断在合格账号间照常工作。图片/视频调度暂不参与。', + disabledHint: '关闭后调度不做利润过滤,账号倍率高于下游倍率的账号也会被选中,可能产生亏损请求。', + minMargin: '最低毛利率(%)', + minMarginHint: '百分比输入,如 30 表示 30%;后端按小数存储', + safetyBuffer: '安全缓冲(%)', + safetyBufferHint: '与最低毛利率相加后从下游倍率中扣除,默认 0', + marginRangeError: '最低毛利率应在 0 到 99.99 之间', + bufferRangeError: '安全缓冲应在 0 到 99.99 之间', + sumTooHigh: '最低毛利率与安全缓冲之和必须小于 100%,否则将排除全部账号' + }, modelsList: { title: '自定义 /v1/models 模型列表', hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。', diff --git a/frontend/src/i18n/locales/zh/admin/promptAudit.ts b/frontend/src/i18n/locales/zh/admin/promptAudit.ts index 8e71f12021..1177c2de14 100644 --- a/frontend/src/i18n/locales/zh/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/zh/admin/promptAudit.ts @@ -55,7 +55,7 @@ export default { searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组', scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。', }, - saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' }, + saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', blockingLatestTurnOnly: '仅审最新输入和上一轮输出', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' }, blockingConfirm: { title: '开启同步阻止?', message: '适用请求会在账号选择、计费和访问上游之前等待 Guard。命中 Block、Guard 不可用或响应非法时,请求都不会访问上游。', diff --git a/frontend/src/i18n/locales/zh/admin/resources.ts b/frontend/src/i18n/locales/zh/admin/resources.ts index b2673188ca..21a7df3bb4 100644 --- a/frontend/src/i18n/locales/zh/admin/resources.ts +++ b/frontend/src/i18n/locales/zh/admin/resources.ts @@ -541,6 +541,10 @@ export default { billingModeImage: '按次(图片)', billingModeVideo: '按次(视频)', allBillingModes: '全部计费模式', + upstreamModelAudit: '上游模型审计', + allUpstreamModelAudit: '全部响应模型状态', + upstreamModelMismatchOnly: '仅不一致', + upstreamModelMatchedOnly: '仅一致', ipAddress: 'IP', clickToViewBalance: '点击查看充值记录', failedToLoadUser: '加载用户信息失败', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 4989305c4d..3967f25ed7 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -16,12 +16,21 @@ export default { features: { channelMonitor: { title: '渠道监控', - description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。', + description: '启用后在 V1 主动探测与 V2 被动用量监控中二选一。关闭后两种模式的后台任务均停止,用户端入口隐藏。', configureLink: '前往 渠道管理 > 渠道监控 配置监控项', enabled: '启用渠道监控', - enabledHint: '关闭后后台不再执行定时检测,已有数据保留。', + enabledHint: '关闭后 V1 调度器与 V2 聚合均停止;已有配置与历史保留。', + mode: '监控模式', + modeHint: '默认 V1(主动探测)。仅在需要被动聚合时切换到 V2;同一时间只能启用一种实现。', + modeV2: 'V2 被动监控', + modeV1: 'V1 主动探测', + modeV2Hint: '需主动选择:基于真实网关流量聚合健康指标,不向上游发送探活请求;启用期间 V1 探测停止。', + modeV1Hint: '默认模式:按配置的渠道监控项定时发起上游健康检查(产生探测流量)。', defaultInterval: '默认检测间隔(秒)', - defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', + defaultIntervalHint: '仅 V1 模式使用:新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。', + hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)', + hideThroughputHint: + '开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。', }, availableChannels: { title: '可用渠道', @@ -119,9 +128,12 @@ export default { emailVerificationHint: '新用户注册时需要验证邮箱', emailSuffixWhitelist: '邮箱域名白名单', emailSuffixWhitelistHint: - "仅允许使用指定域名的邮箱注册账号(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", + "仅允许使用指定域名的邮箱注册账号;留空则不限制(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)", emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn", emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。', + emailDomainQuota: '非白名单域名限量注册', + emailDomainQuotaHint: + '开启后,白名单非空时,其他可注册主域名各限注册一个账户;关闭时非白名单域名直接拒绝注册。白名单为空时本开关无效果', promoCode: '优惠码', promoCodeHint: '允许用户在注册时使用优惠码', invitationCode: '邀请码注册', @@ -144,6 +156,7 @@ export default { passkeyRPID: 'RP ID', passkeyOrigins: '允许的 HTTPS 来源', passkeyValueNotConfigured: '未配置', + passkeyDeploymentHint: '请由服务器运维在部署配置中将 webauthn.enabled 设为 true,填写 webauthn.rp_id(仅域名)与 webauthn.rp_origins(完整 HTTPS 来源),然后重启服务。', stepUp: '敏感操作二次验证 (step-up 2FA)', stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。', stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。', @@ -182,6 +195,58 @@ export default { secretKeyHint: '服务端验证密钥(请保密)', secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, + captcha: { + title: '人机验证', + description: '登录和注册的机器人防护', + enable: '启用人机验证', + enableHint: '开启后登录、注册等入口需要通过人机验证', + provider: '验证服务商', + providerTurnstile: 'Cloudflare Turnstile', + providerTencent: '腾讯天御验证码', + providerAliyun: '阿里云验证码 2.0' + }, + tencentCaptcha: { + title: '腾讯天御验证码', + description: '为登录、注册及第三方登录创建账号流程提供滑动验证码保护', + enable: '启用腾讯天御验证码', + enableHint: '启用后将在所有原 Turnstile 场景使用腾讯滑动验证码', + keepExisting: '留空以保留当前值', + configured: '已配置,留空不会覆盖。', + required: '启用前必须填写此项。', + mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。', + region: '服务站点', + regionCn: '中国站', + regionIntl: '国际站', + regionHint: '决定前端加载的 SDK 脚本与服务端校验接入点,需与 CaptchaAppId 所属站点一致;国际站请在 tencentcloud.com 控制台创建验证', + appCredentialsTitle: '验证码应用密钥', + appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。', + cloudCredentialsTitle: '云 API 调用密钥', + cloudCredentialsHint: 'SecretId 与 SecretKey 用于服务端调用 DescribeCaptchaResult 接口。', + appId: 'CaptchaAppId', + appSecretKey: 'AppSecretKey', + cloudSecretId: '腾讯云 SecretId', + cloudSecretKey: '腾讯云 SecretKey', + camPermissionHint: '推荐创建 CAM 子用户并授予 QcloudCaptchaFullAccess,避免使用主账号永久密钥。', + aidEncryptedHint: '当前版本暂不支持 aidEncrypted,请先不要在验证码控制台开启 CaptchaAppId 强制校验。', + openCaptchaConsole: '打开验证码控制台', + createCloudKeys: '创建 SecretId / SecretKey', + openWebDocs: '查看 Web 接入文档' + }, + aliyunCaptcha: { + accessKeyId: 'AccessKey ID', + accessKeyIdHint: '用于服务端验证的阿里云 AccessKey ID,建议使用仅含验证码权限的 RAM 子账号', + accessKeySecret: 'AccessKey Secret', + accessKeySecretHint: '服务端验证密钥(请保密)', + accessKeySecretConfiguredHint: '密钥已配置,留空以保留当前值。', + sceneId: '场景 ID', + sceneIdHint: '在阿里云验证码控制台创建验证场景后获取;验证方式(无痕/滑块/拼图)在控制台按场景配置', + prefix: '身份标(prefix)', + prefixHint: '在控制台概览页实例基本信息中获取', + region: '服务地域', + regionCn: '中国内地', + regionSgp: '新加坡', + regionHint: '决定前端脚本接入区域与服务端接入点,需与阿里云验证码实例所属地域一致' + }, apiKeyAcl: { title: 'API Key IP 访问控制', description: '控制 API Key 白/黑名单、操作审计日志与会话 IP/UA 绑定使用哪个客户端 IP 判断', @@ -349,11 +414,16 @@ export default { title: '网关调度设置', description: '控制 API Key 的调度行为', allowUngroupedKey: '允许未分组 Key 调度', - allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。' + allowUngroupedKeyHint: '关闭后,未分配到任何分组的 API Key 将无法发起请求(返回 403)。建议保持关闭以确保所有 Key 都归属明确的分组。', + accountSchedulingThresholdsTitle: '平台账号自动停调阈值', + accountSchedulingThresholdsDescription: '当账号当前原生用量窗口(OpenAI Codex/Anthropic 会话,或 Grok 请求/Token 利用率)达到该百分比时,Sub2API 会临时将其移出调度,直到窗口重置。填 100 表示禁用。', + accountSchedulingThresholdsGlobalHint: '系统级默认值,作用于该平台全部账号。可在账号编辑页对单个账号覆盖。', + accountSchedulingThresholdsDisabledHint: '100 表示禁用该平台自动停调;1–99 表示达到该利用率后暂停调度。', + accountSchedulingThresholdsRangeHint: '整数 1–100(百分比)。仅 OpenAI / Anthropic / Grok。' }, upstreamBillingProbe: { title: '上游倍率自动探测', - description: '定期获取 OpenAI API Key 所连接上游 Sub2API 站点声明的计费倍率。', + description: '定期获取 API Key 账号所连接上游 Sub2API 站点声明的计费倍率;只有另行开启“同步上游声明倍率”的账号才会更新账号倍率。', enabled: '启用全局自动探测', enabledHint: '开启后,仅对账号自身已启用自动检测的账号执行定时探测;关闭后停止所有定时探测,手动探测不受影响。', intervalMinutes: '探测周期(分钟)', @@ -376,6 +446,17 @@ export default { gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', + grokDefaultTextModel: '默认 Grok 文本模型', + grokDefaultTextModelHint: '用于空模型值;仅在右侧开关开启时也用于其他客户端模型命名空间。允许填写自定义 Grok 模型 ID。', + grokCrossClientMap: '映射其他客户端模型到 Grok', + grokCrossClientMapHint: '默认关闭。开启后,GPT、Codex、o 系列和 Claude 模型 ID 会路由到左侧默认 Grok 文本模型。', + grokDefaultBaseURLMode: '默认 Grok 上游', + grokDefaultBaseURLModeHint: '仅用于 Grok 账号未配置显式 base URL 的文本请求;媒体和语音仍使用官方 API 主机。', + grokBaseURLModeCLI: 'CLI 聊天代理', + grokBaseURLModeAPI: '公共 API', + grokBaseURLModeUSEast1: '区域 API(us-east-1)', + grokBaseURLModeUSWest2: '区域 API(us-west-2)', + grokBaseURLModeEUWest1: '区域 API(eu-west-1)', fingerprintUnification: '指纹统一化', fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', metadataPassthrough: 'Metadata 透传', @@ -415,8 +496,14 @@ export default { antigravityUserAgentVersionPlaceholder: '1.23.2', antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', openaiCodexUserAgent: 'OpenAI Codex UA', - openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', - openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。', + openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)', + openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex-tui 形态(推荐)。填写后首段和尾部的版本号仍会被下方版本号同步覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。', + openaiCodexClientVersion: 'Codex 客户端版本号', + openaiCodexClientVersionPlaceholder: '留空则跟随自动同步', + openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。', + openaiCodexVersionAutoSync: '自动同步 Codex 版本号', + openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。', + openaiCodexVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', codexHardeningDesc: @@ -543,6 +630,8 @@ export default { '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。', homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。', + compactHome: '简洁首页', + compactHomeHint: '未设置自定义首页内容时,展示简洁的站点信息页面。', hideCcsImportButton: '隐藏 CCS 导入按钮', hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' }, diff --git a/frontend/src/i18n/locales/zh/channelMonitorV2.ts b/frontend/src/i18n/locales/zh/channelMonitorV2.ts new file mode 100644 index 0000000000..1df03acbd3 --- /dev/null +++ b/frontend/src/i18n/locales/zh/channelMonitorV2.ts @@ -0,0 +1,142 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: '渠道监控', + updating: '正在更新数据', + updatedTo: '更新至 {time}', + partialCoverage: '部分历史覆盖', + bootstrap: { + title: '正在补齐历史监控数据', + description: + '首次启用被动监控时,系统会在后台静默聚合 90 分钟、24 小时、7 天与 30 天窗口;完成后可切换全部时间范围。', + progress: '进度 {percent}%', + working: '后台聚合中…', + }, + timeRange: '时间范围', + clearFilters: '重置', + refreshingFilters: '筛选条件已变化,正在刷新矩阵、趋势和明细…', + switchingData: '正在切换筛选数据…', + summaryAria: '筛选范围整体汇总', + loadFailed: '渠道监控加载失败', + detailLoadFailed: '渠道监控明细加载失败', + otherModels: '其他模型', + ignored: '忽略', + currentUser: '当前用户', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: '平台', allPlatforms: '全部', group: '分组', allGroups: '全部', model: '模型', allModels: '全部', + empty: '暂无可选项', selectedCount: '{count} 项', labelValue: '{label}:{value}' + }, + groupBy: { + label: '展示维度', platform: '平台', platformGroup: '平台 / 分组', platformModel: '平台 / 模型', platformGroupModel: '平台 / 分组 / 模型' + }, + trendView: { label: '趋势视图', pulse: '色块矩阵', line: '折线图' }, + healthMode: { label: '健康显示', overall: '综合', success: '错误率', ttft: '首 Token', cache: '缓存率' }, + tabs: { aria: '明细维度', models: '模型', errors: '错误原因', users: '用户排行' }, + metrics: { + rpm: 'RPM', + tpm: 'TPM', + tps: '每秒 Token', + rpmDetail: '每分钟请求数', + tpmDetail: '每分钟 Token 数', + tpsDetail: '由 TPM ÷ 60 换算', + errorRate: '错误率', + ttft: '首 Token', + ttftP50: '首 Token P50', + durationP50: '请求时长 P50', + cacheRate: '缓存率', + cacheDetail: '读缓存占比', + successRate: '成功率', + successRateValue: '成功率 {value}', + errorRateValue: '错误率 {value}', + rpmValue: 'RPM {value}', + tpmValue: 'TPM {value}', + tpsValue: '每秒 Token {value}', + ttftValue: '首 Token {value}', + durationValue: '请求时长 {value}', + cacheRateValue: '缓存率 {value}', + }, + table: { platformModel: '平台 / 模型', rank: '排名', user: '用户' }, + empty: { title: '没有可展示的数据', description: '尝试调整时间范围或筛选条件' }, + bucket: { minutes: '{count} 分钟粒度', hours: '{count} 小时粒度', days: '{count} 天粒度' }, + matrix: { + title: '可用性趋势', description: '每行是一种渠道组合,每个色块代表一个统计区间;悬停查看明细', wheelZoom: '在色块上滚轮放大(区间变窄、色块变宽)', wheelZoomX: '在色块上滚轮放大(区间变窄、色块变宽)', dimension: '渠道维度', emptyTitle: '当前筛选窗口没有矩阵数据', legendAria: '健康分数图例', bad: '差', good: '好', healthyLegend: '健康 (≥80)', warningLegend: '需关注 (50–79)', criticalLegend: '异常 (<50)', unknownLegend: '无流量 / 样本不足', noTraffic: '该区间无流量', noTrafficAt: '{time} · 无流量', scoreLine: '健康分 {score}', resetZoom: '重置缩放' + }, + chart: { + title: '可用性趋势', description: '平滑趋势:错误率 · 首 Token P50 · 缓存率', emptyTitle: '当前筛选窗口没有趋势数据', errorLegend: '错误率(左轴 %)', cacheLegend: '缓存率(左轴 %)', ttftLegend: '首 Token P50(右轴)', errorDataset: '错误率趋势 %', cacheDataset: '缓存率趋势 %', ttftDataset: '首 Token 趋势 P50 (ms)', percentAxis: '比率 %', resetZoom: '重置缩放' + }, + errorDetail: { http: 'HTTP {code}', upstream: '上游 {code}', noMessage: '无错误消息', empty: '仅展示分类占比(样本消息仅管理员可见)' }, + errorCategories: { + content_policy: '内容策略', authentication: '认证失败', context_limit: '上下文超限', invalid_request: '请求格式', model_unsupported: '模型不支持', group_access: '分组权限', quota_or_balance: '额度或余额', account_pool_unavailable: '账号池不可用', rate_or_capacity: '限流或容量', timeout: '超时', transport_or_stream: '传输或流', upstream_forbidden: '上游拒绝', not_found: '资源不存在', client_cancelled: '客户端取消', upstream_5xx: '上游 5xx', internal: '内部错误', other: '其他' + }, + rank: { + gold: '第 1 名 金', + silver: '第 2 名 银', + bronze: '第 3 名 铜', + place: '第 {n} 名', + unranked: '未上榜', + }, + settings: { + title: 'V2 数据监控配置', + description: + '配置被动用量汇总维度(平台 / 模型 / 分组)与刷新频率。健康色与明细在用户端 /monitor 以比例、RPM/TPM 展示,不暴露绝对请求量。', + save: '保存', + loading: '加载中...', + loadFailed: 'V2 配置加载失败', + saveSuccess: 'V2 监控配置已保存', + saveFailed: 'V2 配置保存失败', + modeBanner: + '当前系统设置为 {mode}。V2 分钟聚合不会运行;此处配置可预先保存,切换到 {modeV2} 后立即生效。可在系统设置 → 功能开关调整。', + modeClosed: '渠道监控已关闭', + modeV1: 'V1 主动探测', + modeV2: 'V2 被动监控', + enableTitle: '启用 V2 汇总', + enableHint: '在系统模式为 V2 时生效;关闭后仅停止本配置的汇总,系统模式开关仍在「功能开关」', + refreshTitle: '汇总频率', + refreshHint: '影响矩阵时间粒度与刷新节奏', + refreshAria: '汇总频率', + platformsTitle: '平台与模型', + platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」', + modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)', + badgeAllModels: '全部模型', + badgeOther: '+ 其他', + groupsTitle: '监控分组', + groupsSelected: '已选择 {count} 个分组', + groupsAll: '全部分组', + groupsEmpty: '没有可选择的分组', + errorsTitle: '错误分类与忽略', + errorsHint: + '勾选「忽略」的类别不计入错误率与健康分,仍在错误原因列表中以灰色显示并标记忽略。未匹配的错误归入「其他」。', + ignoredSummary: '已忽略 {ignored} 类 · 计入错误率 {counted} 类', + healthTitle: '健康阈值', + healthHint: '控制用户端色块和整体评分。默认阈值较宽松,避免少量错误或低缓存率立即显示异常。', + fields: { + minimumSample: '最小样本数', + warningError: '错误率关注 %', + criticalError: '错误率异常 %', + targetTtft: 'TTFT 目标 ms', + warningTtft: 'TTFT 关注 ms', + criticalTtft: 'TTFT 异常 ms', + warningCache: '缓存率关注 %', + criticalCache: '缓存率异常 %', + }, + namedModelsEmpty: '各平台模型列表为空:将展示全部真实模型名(不归入「其他」)。', + namedModelsCount: '将展示 {count} 个命名模型维度;名单外模型归入各平台「其他」。', + userContractTitle: '用户端展示约定', + userContract: { + health: '健康色三指标:错误率 60% + 首 Token P50 20% + 缓存率 20%(阈值可在上方配置)', + trend: '趋势可切换色块矩阵 / 折线图(错误率 · 缓存率 · 首 Token)', + latency: '延迟展示 AVG · P50 · P90;不展示绝对请求数 / 错误数', + models: '模型列表留空时展示真实模型名,不会全部归入「其他」', + }, + }, + admin: { + descriptionV1: '当前系统设置为 V1 主动探测:可管理监控项并立即检测;V2 聚合不会运行。', + descriptionV2: '当前系统设置为 V2 被动监控:配置聚合维度;V1 主动探测不会运行。', + tabAria: '监控管理', + tabV2: 'V2 数据监控配置', + tabV1Active: 'V1 主动探测', + tabV1History: 'V1 历史(当前模式未启用探测)', + }, + }, +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 0deb1b9573..04dd321287 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -81,6 +81,13 @@ export default { view: '查看', settings: '设置', chooseFile: '选择文件', + upload: '上传', + remove: '移除', + noFileSelected: '未选择文件', + selectedFile: '已选:{name}', + fileReadFailed: '读取文件失败', + selectImageFile: '请选择图片文件', + fileTooLargeKb: '文件过大({size} KB),上限 {max} KB', copy: '复制', notAvailable: '不可用', now: '现在', @@ -234,6 +241,8 @@ export default { USER_NOT_ACTIVE: '账号已被禁用', }, registrationFailed: '注册失败,请重试。', + emailDomainRegistrationLimit: + '该邮箱域名无法注册新账户。请使用主流邮箱注册;如需使用企业邮箱,请联系客服添加域名白名单。', emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。', emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}', emailSuffixAllowedMore: '等 {count} 项', @@ -242,6 +251,10 @@ export default { reloginRequired: '会话已过期,请重新登录。', turnstileExpired: '验证已过期,请重试', turnstileFailed: '验证失败,请重试', + captchaVerified: '验证已完成', + captchaLoading: '正在加载验证码…', + captchaClickToVerify: '点击完成人机验证', + captchaVerifying: '验证中…', completeVerification: '请完成验证', verifyYourEmail: '验证您的邮箱', sessionExpired: '会话已过期', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index c28f689631..1d9b8f4574 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -173,16 +173,24 @@ export default { note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' }, grok: { - description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。', + description: + '配置 Grok CLI、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。文本模型走 Responses;图片/视频使用 Imagine 模型 ID 与媒体端点。', claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。', codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。', - configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。', - codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。', - note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。', - noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。', - claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。', - codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。', - codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。' + configTomlHint: + '官方路径:~/.grok/config.toml(或 $GROK_HOME)。请填写 [endpoints](models_base_url / models_list_url / xai_api_base_url / cli_chat_proxy_base_url)、[auth] preferred_method=api_key、[models]、[session]、[features] 图片/视频覆盖。优先 env_key,勿硬编码 api_key;文本模型必须 api_backend=responses。合并前备份,保存后运行 grok inspect。', + codexConfigTomlHint: + 'Codex 官方:wire_api 仅支持 "responses";优先 env_key,勿与 experimental_bearer_token 混用;非 OpenAI 网关默认 supports_websockets = false(Sub2API 仍可接客户端 WS 并桥接到 HTTP/SSE)。合并前备份 ~/.codex/config.toml。', + note: + '导出 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml(endpoints/auth/models/session/features)保存为 ~/.grok/config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。', + noteWindows: + '设置 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将完整 config.toml 保存为 %USERPROFILE%\\.grok\\config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。', + claudeNote: + '二选一:终端环境变量仅当前会话;~/.claude/settings.json 可持久化。请勿把含 API Key 的文件提交到仓库。', + codexNote: + '导出 SUB2API_API_KEY,将 config.toml 保存到 ~/.codex(可用 mkdir -p ~/.codex)。优先 env_key,勿提交密钥。', + codexNoteWindows: + '设置 $env:SUB2API_API_KEY,将 config.toml 保存到 %USERPROFILE%\\.codex。优先 env_key,勿提交密钥。' }, opencode: { title: 'OpenCode 配置示例', @@ -300,6 +308,11 @@ export default { model: '模型', requestedModel: '请求', upstreamModel: '上游', + sentUpstreamModel: '发往上游', + upstreamResponseModel: '上游响应', + upstreamModelMismatch: '上游响应模型不一致', + modelVariant: '疑似版本变体', + modelMismatch: '模型不一致', reasoningEffort: '推理强度', endpoint: '端点', endpointDistribution: '端点分布', diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts index a855df543a..ac2867bfda 100644 --- a/frontend/src/i18n/locales/zh/index.ts +++ b/frontend/src/i18n/locales/zh/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index d936f1d2ed..d5cee36065 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -33,6 +33,7 @@ function createPublicSettings(overrides: Partial = {}): PublicSe contact_info: '', doc_url: '', home_content: '', + compact_home_enabled: false, hide_ccs_import_button: false, payment_enabled: false, risk_control_enabled: false, @@ -452,6 +453,7 @@ describe('useAppStore', () => { contact_info: '', doc_url: '', home_content: '', + compact_home_enabled: false, hide_ccs_import_button: false, purchase_subscription_enabled: false, purchase_subscription_url: '', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 9839170a29..e2d614ec3d 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -335,6 +335,10 @@ export const useAppStore = defineStore('app', () => { invitation_code_enabled: false, turnstile_enabled: false, turnstile_site_key: '', + aliyun_captcha_enabled: false, + aliyun_captcha_scene_id: '', + aliyun_captcha_prefix: '', + aliyun_captcha_region: 'cn', site_name: siteName.value, site_logo: siteLogo.value, site_subtitle: '', @@ -342,6 +346,7 @@ export const useAppStore = defineStore('app', () => { contact_info: contactInfo.value, doc_url: docUrl.value, home_content: '', + compact_home_enabled: false, hide_ccs_import_button: false, payment_enabled: false, table_default_page_size: 20, diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index c0f3477d3b..26da19f832 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -6,7 +6,13 @@ import { defineStore } from 'pinia' import { ref, computed, readonly } from 'vue' import { authAPI, isTotp2FARequired, passkeyAPI, type LoginResponse } from '@/api' -import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' +import type { + User, + LoginRequest, + RegisterRequest, + AuthResponse, + ActionCaptchaRequestProof +} from '@/types' const AUTH_TOKEN_KEY = 'auth_token' const AUTH_USER_KEY = 'auth_user' @@ -275,9 +281,9 @@ export const useAuthStore = defineStore('auth', () => { } } - async function loginWithPasskey(): Promise { + async function loginWithPasskey(proof?: ActionCaptchaRequestProof): Promise { try { - const response = await passkeyAPI.login() + const response = await passkeyAPI.login(proof) setAuthFromResponse(response) return user.value! } catch (error) { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 31b4fd8911..24498cf7ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -115,6 +115,19 @@ export interface LoginRequest { email: string password: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string +} + +export interface TencentCaptchaRequestProof { + tencent_captcha_ticket: string + tencent_captcha_randstr: string +} + +// 动作触发式验证码(OAuth 启动、passkey 等入口)的请求凭据: +// 腾讯填 tencent_captcha_*,阿里云的 captchaVerifyParam 复用 turnstile_token 字段 +export interface ActionCaptchaRequestProof extends Partial { + turnstile_token?: string } export interface RegisterRequest { @@ -122,6 +135,8 @@ export interface RegisterRequest { password: string verify_code?: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string promo_code?: string invitation_code?: string aff_code?: string @@ -156,6 +171,8 @@ export interface AffiliateTransferResponse { export interface SendVerifyCodeRequest { email: string turnstile_token?: string + tencent_captcha_ticket?: string + tencent_captcha_randstr?: string pending_auth_token?: string pending_oauth_token?: string } @@ -192,6 +209,7 @@ export interface PublicSettings { email_verify_enabled: boolean force_email_on_third_party_signup: boolean registration_email_suffix_whitelist: string[] + registration_email_domain_quota_enabled?: boolean promo_code_enabled: boolean password_reset_enabled: boolean invitation_code_enabled: boolean @@ -201,8 +219,15 @@ export interface PublicSettings { login_agreement_revision?: string login_agreement_documents?: LoginAgreementDocument[] turnstile_enabled: boolean + tencent_captcha_enabled?: boolean + tencent_captcha_app_id?: string + tencent_captcha_region?: string passkey_enabled?: boolean turnstile_site_key: string + aliyun_captcha_enabled?: boolean + aliyun_captcha_scene_id?: string + aliyun_captcha_prefix?: string + aliyun_captcha_region?: string site_name: string site_logo: string site_subtitle: string @@ -210,6 +235,7 @@ export interface PublicSettings { contact_info: string doc_url: string home_content: string + compact_home_enabled: boolean hide_ccs_import_button: boolean payment_enabled: boolean risk_control_enabled: boolean @@ -237,7 +263,11 @@ export interface PublicSettings { account_quota_notify_enabled: boolean balance_low_notify_threshold: number channel_monitor_enabled: boolean + /** Exclusive mode: v1 active probes or v2 passive aggregation. Default v2. */ + channel_monitor_mode?: 'v1' | 'v2' channel_monitor_default_interval_seconds: number + /** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */ + channel_monitor_hide_throughput?: boolean available_channels_enabled: boolean model_plaza_enabled: boolean model_plaza_require_auth: boolean @@ -497,6 +527,8 @@ export interface PaginationConfig { export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok' | 'composite' +export type VideoModelPrices = Record> + export type SubscriptionType = 'standard' | 'subscription' export interface OpenAIMessagesDispatchModelConfig { @@ -541,8 +573,15 @@ export interface Group { video_price_480p: number | null video_price_720p: number | null video_price_1080p: number | null + // Optional model-family x resolution overrides for Grok video pricing. + video_model_prices?: VideoModelPrices // Codex 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 web_search_price_per_call: number | null + // Grok Voice 显式定价(分组级) + search_price_per_1k: number | null + audio_realtime_price_per_min: number | null + audio_tts_price_per_million_chars: number | null + audio_stt_price_per_hour: number | null // 高峰时段倍率配置 peak_rate_enabled: boolean peak_start: string @@ -565,6 +604,12 @@ export interface Group { } export interface AdminGroup extends Group { + // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 + // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 + profit_control_enabled: boolean + profit_min_margin: number + profit_safety_buffer: number + // 模型路由配置(仅管理员可见,内部信息) model_routing: Record | null model_routing_enabled: boolean @@ -735,11 +780,20 @@ export interface CreateGroupRequest { video_price_480p?: number | null video_price_720p?: number | null video_price_1080p?: number | null + video_model_prices?: VideoModelPrices web_search_price_per_call?: number | null + search_price_per_1k?: number | null + audio_realtime_price_per_min?: number | null + audio_tts_price_per_million_chars?: number | null + audio_stt_price_per_hour?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null @@ -786,11 +840,20 @@ export interface UpdateGroupRequest { video_price_480p?: number | null video_price_720p?: number | null video_price_1080p?: number | null + video_model_prices?: VideoModelPrices web_search_price_per_call?: number | null + search_price_per_1k?: number | null + audio_realtime_price_per_min?: number | null + audio_tts_price_per_million_chars?: number | null + audio_stt_price_per_hour?: number | null peak_rate_enabled?: boolean peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null @@ -933,6 +996,9 @@ export interface TempUnschedulableState { matched_keyword: string rule_index: number error_message: string + trigger_count?: number + trigger_threshold?: number + trigger_window_minutes?: number } export interface TempUnschedulableStatus { @@ -969,6 +1035,9 @@ export interface UpstreamBillingProbeSnapshot { failure_count?: number http_status?: number last_error?: string + // Value this probe wrote into the account rate multiplier; absent when the + // probe did not sync a rate. + synced_rate_multiplier?: number } export interface UpstreamBillingProbeSettings { @@ -1050,7 +1119,12 @@ export interface Account { model_rate_limits?: Record antigravity_credits_overages?: Record upstream_billing_probe_enabled?: boolean + upstream_billing_rate_sync_enabled?: boolean upstream_billing_probe?: UpstreamBillingProbeSnapshot + codex_reset_credit_snapshot?: { + available_count?: number + credits?: { expires_at?: string }[] + } } & Record) proxy_id: number | null proxy_fallback_origin_id?: number | null @@ -1213,6 +1287,14 @@ export interface GrokBillingSummary { billing_period_start?: string billing_period_end?: string used_percent?: number | null + /** Absolute USD money from billing probes */ + prepaid_balance?: number | null + monthly_limit?: number | null + monthly_used?: number | null + on_demand_cap?: number | null + on_demand_used?: number | null + top_up_method?: string + is_unified_billing_user?: boolean plan?: string status_code?: number source?: string @@ -1231,6 +1313,7 @@ export interface AccountUsageInfo { seven_day: UsageProgress | null seven_day_sonnet: UsageProgress | null seven_day_fable?: UsageProgress | null + thirty_day?: UsageProgress | null gemini_shared_daily?: UsageProgress | null gemini_pro_daily?: UsageProgress | null gemini_flash_daily?: UsageProgress | null @@ -1353,6 +1436,8 @@ export interface UpdateAccountRequest { group_ids?: number[] expires_at?: number | null auto_pause_on_expired?: boolean + upstream_billing_probe_enabled?: boolean + upstream_billing_rate_sync_enabled?: boolean confirm_mixed_channel_risk?: boolean } @@ -1600,6 +1685,8 @@ export interface UsageLogAccountSummary { export interface AdminUsageLog extends UsageLog { upstream_model?: string | null + upstream_response_model?: string | null + upstream_model_mismatch?: boolean | null model_mapping_chain?: string | null // 账号计费倍率(仅管理员可见) @@ -1830,6 +1917,7 @@ export interface UserUsageTrendPoint { export interface UserSpendingRankingItem { user_id: number email: string + username: string actual_cost: number requests: number tokens: number diff --git a/frontend/src/utils/__tests__/accountSelection.spec.ts b/frontend/src/utils/__tests__/accountSelection.spec.ts new file mode 100644 index 0000000000..9581beb284 --- /dev/null +++ b/frontend/src/utils/__tests__/accountSelection.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' + +import { fetchAllAccountIds } from '../accountSelection' + +describe('fetchAllAccountIds', () => { + it('loads every page with the same filter snapshot and returns unique account IDs', async () => { + const fetchPage = vi.fn(async (page: number, pageSize: number, _filters: Record) => { + const start = (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, 2505) + return { + items: Array.from({ length: end - start + 1 }, (_, index) => ({ id: start + index })), + total: 2505, + page, + page_size: pageSize, + pages: 3 + } + }) + + const filters = { + platform: 'grok', + status: 'active', + search: 'example' + } + + const ids = await fetchAllAccountIds(fetchPage, filters) + + expect(ids).toHaveLength(2505) + expect(ids[0]).toBe(1) + expect(ids[2504]).toBe(2505) + expect(fetchPage).toHaveBeenCalledTimes(3) + expect(fetchPage).toHaveBeenNthCalledWith(1, 1, 1000, { + ...filters, + lite: '1', + include_scheduler_score: '0' + }) + expect(fetchPage).toHaveBeenNthCalledWith(3, 3, 1000, { + ...filters, + lite: '1', + include_scheduler_score: '0' + }) + }) + + it('rejects an incomplete or duplicated result instead of returning a partial selection', async () => { + const fetchPage = vi.fn().mockResolvedValue({ + items: [{ id: 1 }, { id: 1 }], + total: 2, + page: 1, + page_size: 1000, + pages: 1 + }) + + await expect(fetchAllAccountIds(fetchPage, {})).rejects.toThrow('账号列表结果不完整') + }) + + it('propagates a later page failure', async () => { + const fetchPage = vi.fn() + .mockResolvedValueOnce({ + items: Array.from({ length: 1000 }, (_, index) => ({ id: index + 1 })), + total: 1001, + page: 1, + page_size: 1000, + pages: 2 + }) + .mockRejectedValueOnce(new Error('page 2 failed')) + + await expect(fetchAllAccountIds(fetchPage, { group: '7' })).rejects.toThrow('page 2 failed') + }) +}) diff --git a/frontend/src/utils/__tests__/billingMode.spec.ts b/frontend/src/utils/__tests__/billingMode.spec.ts new file mode 100644 index 0000000000..d7009b0eea --- /dev/null +++ b/frontend/src/utils/__tests__/billingMode.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + BILLING_MODE_IMAGE, + BILLING_MODE_TOKEN, + BILLING_MODE_VIDEO, + getDisplayBillingMode, + isImageUsage +} from '../billingMode' + +describe('billingMode helpers', () => { + it('prefers explicit video mode over image_count', () => { + expect( + getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_VIDEO }) + ).toBe(BILLING_MODE_VIDEO) + expect(isImageUsage({ image_count: 1, billing_mode: BILLING_MODE_VIDEO })).toBe(false) + }) + + it('infers image when image_count set and mode missing', () => { + expect(getDisplayBillingMode({ image_count: 2, billing_mode: null })).toBe(BILLING_MODE_IMAGE) + }) + + it('keeps token mode even with image_count', () => { + expect( + getDisplayBillingMode({ image_count: 1, billing_mode: BILLING_MODE_TOKEN }) + ).toBe(BILLING_MODE_TOKEN) + }) +}) diff --git a/frontend/src/utils/__tests__/subscriptionQuota.spec.ts b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts new file mode 100644 index 0000000000..9cc8bc7071 --- /dev/null +++ b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { getExpirationDateRelation, getRemainingExpiryDuration } from '../subscriptionQuota' + +describe('subscription expiry timing', () => { + it('uses local calendar dates for today and tomorrow', () => { + const now = new Date(2026, 2, 7, 23, 30) + + expect(getExpirationDateRelation(new Date(2026, 2, 7, 23, 45), now)).toBe('today') + expect(getExpirationDateRelation(new Date(2026, 2, 8, 3, 30), now)).toBe('tomorrow') + }) + + it('treats the exact expiry instant and elapsed expiries as expired', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(now, now)).toBe('expired') + expect(getRemainingExpiryDuration(now, now)).toBeNull() + expect(getExpirationDateRelation(new Date(2026, 6, 30, 8, 59), now)).toBe('expired') + expect(getRemainingExpiryDuration(new Date(2026, 6, 30, 8, 59), now)).toBeNull() + }) + + it('rejects invalid target and current dates', () => { + const invalid = new Date('invalid') + const valid = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(invalid, valid)).toBeNull() + expect(getExpirationDateRelation(valid, invalid)).toBeNull() + expect(getRemainingExpiryDuration(invalid, valid)).toBeNull() + expect(getRemainingExpiryDuration(valid, invalid)).toBeNull() + }) + + it('returns rounded-up hours and minutes for an expiry under 24 hours away', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(2026, 6, 31, 8, 30), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 30 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 0, + minutes: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 23 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 1 + }) + }) + + it('preserves rounded-up day display from 24 hours onward', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000), now)).toEqual({ + unit: 'days', + days: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'days', + days: 2 + }) + }) +}) diff --git a/frontend/src/utils/accountSelection.ts b/frontend/src/utils/accountSelection.ts new file mode 100644 index 0000000000..7afc1dfda2 --- /dev/null +++ b/frontend/src/utils/accountSelection.ts @@ -0,0 +1,45 @@ +interface AccountIDRow { + id: number +} + +interface AccountListPage { + items: AccountIDRow[] + total: number + pages?: number +} + +type AccountPageFetcher = ( + page: number, + pageSize: number, + filters: Record +) => Promise + +const SELECT_ALL_PAGE_SIZE = 1000 + +export async function fetchAllAccountIds( + fetchPage: AccountPageFetcher, + filters: Record +): Promise { + const requestFilters = { + ...filters, + lite: '1', + include_scheduler_score: '0' + } + const firstPage = await fetchPage(1, SELECT_ALL_PAGE_SIZE, requestFilters) + const pageCount = Math.max( + firstPage.pages ?? 0, + Math.ceil(firstPage.total / SELECT_ALL_PAGE_SIZE) + ) + const ids = firstPage.items.map(account => account.id) + + for (let page = 2; page <= pageCount; page++) { + const result = await fetchPage(page, SELECT_ALL_PAGE_SIZE, requestFilters) + ids.push(...result.items.map(account => account.id)) + } + + const uniqueIDs = Array.from(new Set(ids)) + if (uniqueIDs.length !== firstPage.total) { + throw new Error('账号列表结果不完整') + } + return uniqueIDs +} diff --git a/frontend/src/utils/billingMode.ts b/frontend/src/utils/billingMode.ts index 1ba03ed377..628441fb72 100644 --- a/frontend/src/utils/billingMode.ts +++ b/frontend/src/utils/billingMode.ts @@ -32,6 +32,10 @@ export function isImageUsage(row: Pick | null | undefined): string | null | undefined { + // Explicit video/token modes always win over image_count heuristics. + if (row?.billing_mode === BILLING_MODE_VIDEO || row?.billing_mode === BILLING_MODE_TOKEN) { + return row.billing_mode + } if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) { return BILLING_MODE_IMAGE } diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts index 5004c068b4..0fc9d8a3ad 100644 --- a/frontend/src/utils/featureFlags.ts +++ b/frontend/src/utils/featureFlags.ts @@ -68,9 +68,9 @@ * `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with * `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry. */ - import { useAppStore } from '@/stores/app' import type { PublicSettings } from '@/types' +import { DEFAULT_INTERVAL_SECONDS } from '@/constants/channelMonitor' export type FeatureFlagMode = 'opt-in' | 'opt-out' @@ -152,3 +152,37 @@ export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean { export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean { return () => isFeatureFlagEnabled(flag) } + +/** True when channel monitor feature flag is enabled. */ +export function isChannelMonitorRouteEnabled(): boolean { + return isFeatureFlagEnabled(FeatureFlags.channelMonitor) +} + +export type ChannelMonitorMode = 'v1' | 'v2' + +/** Exclusive channel-monitor implementation. Invalid/missing → v1 (opt-in to v2). */ +export function getChannelMonitorMode(): ChannelMonitorMode { + const appStore = useAppStore() + const mode = appStore.cachedPublicSettings?.channel_monitor_mode + return mode === 'v2' ? 'v2' : 'v1' +} + +export function isChannelMonitorV1Mode(): boolean { + return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v1' +} + +export function isChannelMonitorV2Mode(): boolean { + return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v2' +} + +export function getChannelMonitorRefreshIntervalSeconds(): number { + const appStore = useAppStore() + const configured = appStore.cachedPublicSettings?.channel_monitor_default_interval_seconds + return configured && configured > 0 ? configured : DEFAULT_INTERVAL_SECONDS +} + +/** Hide RPM/TPM on user-facing monitor (scale privacy). Admin always shows full metrics. */ +export function isChannelMonitorThroughputHidden(): boolean { + const appStore = useAppStore() + return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_throughput) +} diff --git a/frontend/src/utils/subscriptionQuota.ts b/frontend/src/utils/subscriptionQuota.ts index 719737bebc..d4135aa4bc 100644 --- a/frontend/src/utils/subscriptionQuota.ts +++ b/frontend/src/utils/subscriptionQuota.ts @@ -2,6 +2,12 @@ import type { UserSubscription } from '@/types' const ONE_DAY_MS = 24 * 60 * 60 * 1000 +export type ExpirationDateRelation = 'expired' | 'today' | 'tomorrow' | 'later' + +export type RemainingExpiryDuration = + | { unit: 'days'; days: number } + | { unit: 'hoursMinutes'; hours: number; minutes: number } + export interface RemainingDurationParts { days: number hours: number @@ -40,3 +46,46 @@ export function getRemainingDurationParts( return { days, hours, minutes } } + +export function getExpirationDateRelation( + targetAt: Date | string, + now: Date = new Date() +): ExpirationDateRelation | null { + const target = targetAt instanceof Date ? targetAt : new Date(targetAt) + const targetTime = target.getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + if (targetTime <= nowTime) return 'expired' + + const targetDay = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) + const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) + const calendarDays = Math.round((targetDay - currentDay) / ONE_DAY_MS) + + if (calendarDays === 0) return 'today' + if (calendarDays === 1) return 'tomorrow' + return 'later' +} + +export function getRemainingExpiryDuration( + targetAt: Date | string, + now: Date = new Date() +): RemainingExpiryDuration | null { + const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + + const diffMs = targetTime - nowTime + if (diffMs <= 0) return null + if (diffMs >= ONE_DAY_MS) { + return { unit: 'days', days: Math.ceil(diffMs / ONE_DAY_MS) } + } + + const totalMinutes = Math.ceil(diffMs / (60 * 1000)) + return { + unit: 'hoursMinutes', + hours: Math.floor(totalMinutes / 60), + minutes: totalMinutes % 60 + } +} diff --git a/frontend/src/utils/tencentCaptcha.ts b/frontend/src/utils/tencentCaptcha.ts new file mode 100644 index 0000000000..48495549a3 --- /dev/null +++ b/frontend/src/utils/tencentCaptcha.ts @@ -0,0 +1,120 @@ +// 腾讯天御验证码站点:cn=中国站(cloud.tencent.com),intl=国际站(tencentcloud.com)。 +// 两个站点的 CaptchaAppId 不互通,SDK 脚本与服务端校验接入点都必须成对使用。 +export type TencentCaptchaRegion = 'cn' | 'intl' + +export interface TencentCaptchaProof { + ticket: string + randstr: string +} + +export interface TencentCaptchaResult { + ret: number + ticket?: string | null + randstr?: string | null + errorCode?: number + errorMessage?: string +} + +interface TencentCaptchaInstance { + show(): void + destroy(): void +} + +type TencentCaptchaCallback = (result: TencentCaptchaResult) => void + +// 两个站点的构造函数签名不同,且不可互换: +// - 中国站 TJCaptcha.js:第一个参数是 CaptchaAppId 字符串(传 DOM 走另一条分支)。 +// - 国际站 TJNCaptcha-global.js:第一个参数必须是承载 Robot checkbox 的 DOM 容器,传字符串会直接抛 +// "The parameter of the constructor of the Captcha is passed incorrectly"。 +type TencentCaptchaConstructor = { + new ( + appId: string, + callback: TencentCaptchaCallback, + options?: Record + ): TencentCaptchaInstance + new ( + element: HTMLElement, + appId: string, + callback: TencentCaptchaCallback, + options?: Record + ): TencentCaptchaInstance +} + +declare global { + interface Window { + TencentCaptcha?: TencentCaptchaConstructor + // 国际站入口脚本会置为 true;国内站脚本只读取、从不写入。 + // 用于判定页面上已存在的 TencentCaptcha 属于哪个站点。 + TCaptchaGlobal?: boolean + } +} + +const SCRIPT_SRC: Record = { + cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js', + intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js' +} + +export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion { + return value === 'intl' ? 'intl' : 'cn' +} + +let scriptPromise: Promise | null = null +let loadedRegion: TencentCaptchaRegion | null = null + +// existingGlobalRegion 推断页面上已存在的全局构造函数属于哪个站点。 +// 两个站点的构造函数签名不兼容(国际站首参必须是 DOM 元素),错配会直接抛错, +// 因此复用已存在的全局前必须先确认站点一致。 +function existingGlobalRegion(): TencentCaptchaRegion { + return window.TCaptchaGlobal === true ? 'intl' : 'cn' +} + +export function loadTencentCaptcha( + region: TencentCaptchaRegion = 'cn' +): Promise { + // 两个站点的 SDK 注册同名全局 TencentCaptcha,缓存必须按站点隔离, + // 否则管理员切换站点后仍会复用上一个站点的构造函数。 + // loadedRegion 为 null 表示全局不是本模块注入的(如开发期 HMR 后模块状态被重置), + // 此时靠 TCaptchaGlobal 判定站点,一致才复用。 + const globalRegion = window.TencentCaptcha ? existingGlobalRegion() : null + if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) { + return Promise.resolve(window.TencentCaptcha) + } + if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) { + // 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。 + // 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。 + return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it')) + } + if (scriptPromise && loadedRegion === region) return scriptPromise + + loadedRegion = region + scriptPromise = new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = SCRIPT_SRC[region] + script.async = true + script.onload = () => { + // 入口脚本被重复引入时腾讯会在求值阶段抛错("请勿多次引用腾讯验证码的接入js"), + // 此时 onload 仍会触发而全局仍是上一个站点的构造函数。校验站点,避免把 + // 签名不兼容的构造函数当成本站点的返回出去。 + if (window.TencentCaptcha && existingGlobalRegion() === region) { + resolve(window.TencentCaptcha) + return + } + scriptPromise = null + loadedRegion = null + reject(new Error('Tencent Captcha SDK is unavailable')) + } + script.onerror = () => { + scriptPromise = null + loadedRegion = null + reject(new Error('Failed to load Tencent Captcha SDK')) + } + document.head.appendChild(script) + }) + + return scriptPromise +} + +export function resetTencentCaptchaLoaderForTest(): void { + scriptPromise = null + loadedRegion = null +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index e01aa53a50..ddb3e87e1d 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,6 +1,6 @@