diff --git a/.dockerignore b/.dockerignore index d5369692cb..c6167a25b2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,6 +49,9 @@ coverage/ .env.* !.env.example +# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。 +/plugins/ + # Local config config.yaml config.local.yaml diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 6c4a028c96..2bac7259a9 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,7 @@ jobs: /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh /bin/sh deploy/tests/docker-compose-security-test.sh + /bin/sh deploy/tests/docker-compose-gateway-env-test.sh /bin/sh deploy/tests/docker-runtime-resources-test.sh /bin/sh deploy/test-caddyfile-cache.sh @@ -32,7 +33,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: Unit tests working-directory: backend run: make test-unit @@ -72,10 +73,10 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.9 + version: v2.13 args: --timeout=30m working-directory: backend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ecc8a26ba..abdfc9c81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 0e68d950ac..97357efde2 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.6' + go version | grep -q 'go1.27.0' - name: Run govulncheck working-directory: backend run: | diff --git a/.gitignore b/.gitignore index d778c275ec..20ed12275e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -docs/claude-relay-service/ +# 本地杂项文档、测试数据和外部项目副本 +/docs-local/ .codex # =================== @@ -136,8 +137,15 @@ docs/* !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md !docs/ASYNC_IMAGE_TASKS.md +!docs/BATCH_IMAGE_MVP.md +!docs/COMPOSITE_GROUPS.md +!docs/PLUGIN_DEVELOPMENT.md +!docs/channel-monitor-v2-safe-defaults.md !docs/legal/ !docs/legal/*.md +!docs/screenshots/ +docs/screenshots/* +!docs/screenshots/mobile-account-actions-menu.png .serena/ .codex/ frontend/coverage/ @@ -147,3 +155,5 @@ output/ # Vitest / Vite cache at repo root .vite/ +# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库 +/plugins/ diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index bf5e8867d6..2f440d0f62 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -34,8 +34,8 @@ ### 开发工具 ```bash -# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音) -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9 +# golangci-lint(CI 用 v2.13,本地建议装同一版以免版本差异带来的噪音) +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13 # pnpm (前端包管理) npm install -g pnpm @@ -47,13 +47,13 @@ npm install -g pnpm | Workflow | 触发条件 | 检查内容 | |----------|----------|----------| -| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 | +| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.13 | | **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit | | **release.yml** | tag `v*` | 构建发布(PR 不触发) | ### CI 要求 -- Go 版本必须是 **1.26.6**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.6'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 +- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 - 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` ### 本地测试命令 @@ -203,7 +203,7 @@ go test -tags=integration ./... **解决**: ```bash cd backend -go generate ./ent # 重新生成 ent 代码 +go generate ./ent # 重新生成 ent 代码(json.RawMessage 字段会生成为同类型的 jsontext.Value,属预期) git add ent/ # 生成的文件也要提交 ``` diff --git a/Dockerfile b/Dockerfile index c7991c5a60..adfa4d4702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.6-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/README.md b/README.md index 127653ce82..16ebab0a32 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -162,6 +162,11 @@ Please read the following carefully before using this project: DuckIP - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial. + +APIMart +Thanks to APIMart for sponsoring this project! APIMart is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — sign up here to get started. + + ## Overview @@ -194,7 +199,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.26.5, Gin, Ent | +| Backend | Go 1.27.0, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | @@ -640,6 +645,32 @@ Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment. Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out or mitigating upstream WebSocket issues. +#### Force OpenAI upstream HTTP/SSE + +When an egress proxy or network repeatedly reconnects OpenAI Responses +WebSockets, set the global fallback in the persisted deployment configuration: + +```yaml +gateway: + openai_ws: + force_http: true +``` + +For Compose and Apple container deployments, the equivalent `.env` setting is: + +```bash +GATEWAY_OPENAI_WS_FORCE_HTTP=true +``` + +This selects HTTP/SSE for OpenAI upstream Responses traffic that would +otherwise use WebSocket. It does not change the client-facing protocol or force +HTTP/1.1; configure `gateway.openai_http2.enabled` (or +`GATEWAY_OPENAI_HTTP2_ENABLED=false`) separately when a proxy is incompatible +with HTTP/2. Unlike the account-level `http_bridge` mode, this global fallback +takes effect without enabling `mode_router_v2_enabled`. Keep the setting in the +deployment's persisted `.env` or `config.yaml`, rather than inside a running +container, so it is read again after an image update or container recreation. + #### ⚠️ Important: Creating the Admin Account The initial admin account is **only created via the setup wizard** (served at `http://:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons. diff --git a/README_CN.md b/README_CN.md index 034a3819f5..e879045374 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -165,6 +165,11 @@ DuckIP - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。 + +APIMart +感谢 APIMart 赞助了本项目!APIMart 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过此注册链接注册即可开始使用。 + + ## 项目概述 @@ -196,7 +201,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.26.5, Gin, Ent | +| 后端 | Go 1.27.0, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/README_JA.md b/README_JA.md index 384f9daf67..17890e4557 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -164,6 +164,11 @@ DuckIP - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。 + +APIMart +APIMart のご支援に感謝します!APIMart は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、こちらの登録リンクからすぐに利用を開始できます。 + + ## 概要 @@ -195,7 +200,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.26.5, Gin, Ent | +| バックエンド | Go 1.27.0, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | diff --git a/assets/partners/logos/apimart.jpg b/assets/partners/logos/apimart.jpg new file mode 100644 index 0000000000..e670cea236 Binary files /dev/null and b/assets/partners/logos/apimart.jpg differ diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 92ba391694..08d1b61f45 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -121,6 +121,16 @@ linters: # Default: true — must be true, ent generates 130K+ lines of code generated-is-used: true + exclusions: + rules: + # G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的 + # 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的 + # 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免, + # 必须逐点 //nolint:gosec 并写明信任边界。 + - path: '_test\.go$' + linters: [ gosec ] + text: 'G70[34]' + formatters: enable: - gofmt diff --git a/backend/Dockerfile b/backend/Dockerfile index 1efbd3859d..398d9bdfcf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.6-alpine +FROM golang:1.27.0-alpine WORKDIR /app diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f4fdeb66be..ce08f35612 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.179 +0.1.182 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a6fe5d42c3..8494713ba1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -153,6 +153,11 @@ func runMainServer() { log.Fatalf("Failed to initialize application: %v", err) } defer app.Cleanup() + if app.PluginManager != nil { + if err := app.PluginManager.Start(context.Background()); err != nil { + log.Printf("Plugin manager started in degraded state: %v", err) + } + } if app.PromptAudit != nil { if err := app.PromptAudit.Start(context.Background()); err != nil { // Startup continues so unrelated APIs stay up. Fail-closed (unavailable) diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index cf5984372d..06720610ba 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -25,9 +25,10 @@ import ( ) type Application struct { - Server *http.Server - PromptAudit *securityaudit.PromptService - Cleanup func() + Server *http.Server + PromptAudit *securityaudit.PromptService + PluginManager *service.PluginManager + Cleanup func() } func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { @@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // BuildInfo provider provideServiceBuildInfo, + providePluginHostInfo, // Cleanup function provider provideCleanup, // Application struct - wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"), + wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"), ) return nil, nil } @@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo { } } +func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo { + return service.PluginHostInfo{ + Version: buildInfo.Version, + BuildType: buildInfo.BuildType, + } +} + func provideCleanup( entClient *ent.Client, rdb *redis.Client, @@ -116,7 +125,9 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, + pluginManager *service.PluginManager, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -129,6 +140,18 @@ func provideCleanup( // 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。 parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() @@ -328,12 +351,12 @@ func provideCleanup( return nil }}, {"ChannelMonitorV2Aggregator", func() error { - if channelMonitorV2Aggregator != nil { - channelMonitorV2Aggregator.Stop() - } - return nil - }}, - {"ChannelMonitorRunner", func() error { + if channelMonitorV2Aggregator != nil { + channelMonitorV2Aggregator.Stop() + } + return nil + }}, + {"ChannelMonitorRunner", func() error { if channelMonitorRunner != nil { channelMonitorRunner.Stop() } diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 6432afcfba..b2774d7232 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -194,13 +194,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) - antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) + antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) usageCache := service.NewUsageCache() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService) - accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService) + pluginRepository := repository.NewPluginRepository(db) + pluginHostInfo := providePluginHostInfo(buildInfo) + pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo) + accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) @@ -251,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache) errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService) tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService) + pluginHandler := admin.NewPluginHandler(pluginManager) adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService) scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db) scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) @@ -280,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) @@ -300,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) - modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService) + modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver) + modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService) imageTaskStore := repository.NewImageTaskStore(redisClient) imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService) asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler) @@ -314,7 +319,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService) + openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService) optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService) @@ -341,11 +347,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ - Server: httpServer, - PromptAudit: promptService, - Cleanup: v, + Server: httpServer, + PromptAudit: promptService, + PluginManager: pluginManager, + Cleanup: v, } return application, nil } @@ -353,9 +360,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { // wire.go: type Application struct { - Server *http.Server - PromptAudit *securityaudit.PromptService - Cleanup func() + Server *http.Server + PromptAudit *securityaudit.PromptService + PluginManager *service.PluginManager + Cleanup func() } func providePrivacyClientFactory() service.PrivacyClientFactory { @@ -369,6 +377,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo { } } +func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo { + return service.PluginHostInfo{ + Version: buildInfo.Version, + BuildType: buildInfo.BuildType, + } +} + func provideCleanup( entClient *ent.Client, rdb *redis.Client, @@ -413,7 +428,9 @@ func provideCleanup( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, auditLog *service.AuditLogService, + openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, + pluginManager *service.PluginManager, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -425,6 +442,18 @@ func provideCleanup( } parallelSteps := []cleanupStep{ + {"PluginManager", func() error { + if pluginManager != nil { + pluginManager.Stop() + } + return nil + }}, + {"OpenAIQuotaAutoResetService", func() error { + if openAIAutoReset != nil { + openAIAutoReset.Stop() + } + return nil + }}, {"OpsIngressRejectAggregator", func() error { if opsIngressReject != nil { opsIngressReject.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 41e41bce13..b3581641ea 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -94,7 +94,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // upstreamBillingProbe nil, // ollamaCloudUsage nil, // auditLog + nil, // openAIAutoReset nil, // promptAudit + nil, // pluginManager ) require.NotPanics(t, func() { diff --git a/backend/ent/group.go b/backend/ent/group.go index c9795fb58f..8f30c26470 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -100,7 +101,7 @@ type Group struct { // 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价 LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"` // 分组逐模型定价;优先级高于渠道和内置定价 - ModelPricing json.RawMessage `json:"model_pricing,omitempty"` + ModelPricing jsontext.Value `json:"model_pricing,omitempty"` // 是否仅允许 Claude Code 客户端 ClaudeCodeOnly bool `json:"claude_code_only,omitempty"` // 非 Claude Code 请求降级使用的分组 ID diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 158dca1e40..e5e92ccd3f 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -575,7 +575,7 @@ func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreat } // SetModelPricing sets the "model_pricing" field. -func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate { +func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate { _c.mutation.SetModelPricing(v) return _c } @@ -2445,7 +2445,7 @@ func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert { +func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert { u.Set(group.FieldModelPricing, v) return u } @@ -3615,7 +3615,7 @@ func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne { +func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) @@ -5005,7 +5005,7 @@ func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk { } // SetModelPricing sets the "model_pricing" field. -func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk { +func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { s.SetModelPricing(v) }) diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index beca0c71f0..41e06b4471 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -803,13 +803,13 @@ func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdat } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate { +func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate { _u.mutation.AppendModelPricing(v) return _u } @@ -2924,13 +2924,13 @@ func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUp } // SetModelPricing sets the "model_pricing" field. -func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.SetModelPricing(v) return _u } // AppendModelPricing appends value to the "model_pricing" field. -func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne { +func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne { _u.mutation.AppendModelPricing(v) return _u } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index ddf50a1c42..f777a0a5fb 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "sync" @@ -22142,8 +22142,8 @@ type GroupMutation struct { audio_stt_price_per_hour *float64 addaudio_stt_price_per_hour *float64 long_context_pricing_enabled *bool - model_pricing *json.RawMessage - appendmodel_pricing json.RawMessage + model_pricing *jsontext.Value + appendmodel_pricing jsontext.Value claude_code_only *bool fallback_group_id *int64 addfallback_group_id *int64 @@ -24404,13 +24404,13 @@ func (m *GroupMutation) ResetLongContextPricingEnabled() { } // SetModelPricing sets the "model_pricing" field. -func (m *GroupMutation) SetModelPricing(jm json.RawMessage) { - m.model_pricing = &jm +func (m *GroupMutation) SetModelPricing(j jsontext.Value) { + m.model_pricing = &j m.appendmodel_pricing = nil } // ModelPricing returns the value of the "model_pricing" field in the mutation. -func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { +func (m *GroupMutation) ModelPricing() (r jsontext.Value, exists bool) { v := m.model_pricing if v == nil { return @@ -24421,7 +24421,7 @@ func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) { // OldModelPricing returns the old "model_pricing" field's value of the Group entity. // If the Group object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) { +func (m *GroupMutation) OldModelPricing(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldModelPricing is only allowed on UpdateOne operations") } @@ -24435,13 +24435,13 @@ func (m *GroupMutation) OldModelPricing(ctx context.Context) (v json.RawMessage, return oldValue.ModelPricing, nil } -// AppendModelPricing adds jm to the "model_pricing" field. -func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) { - m.appendmodel_pricing = append(m.appendmodel_pricing, jm...) +// AppendModelPricing adds j to the "model_pricing" field. +func (m *GroupMutation) AppendModelPricing(j jsontext.Value) { + m.appendmodel_pricing = append(m.appendmodel_pricing, j...) } // AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation. -func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) { +func (m *GroupMutation) AppendedModelPricing() (jsontext.Value, bool) { if len(m.appendmodel_pricing) == 0 { return nil, false } @@ -26515,7 +26515,7 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { m.SetLongContextPricingEnabled(v) return nil case group.FieldModelPricing: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } @@ -43151,8 +43151,8 @@ type UsageCleanupTaskMutation struct { created_at *time.Time updated_at *time.Time status *string - filters *json.RawMessage - appendfilters json.RawMessage + filters *jsontext.Value + appendfilters jsontext.Value created_by *int64 addcreated_by *int64 deleted_rows *int64 @@ -43376,13 +43376,13 @@ func (m *UsageCleanupTaskMutation) ResetStatus() { } // SetFilters sets the "filters" field. -func (m *UsageCleanupTaskMutation) SetFilters(jm json.RawMessage) { - m.filters = &jm +func (m *UsageCleanupTaskMutation) SetFilters(j jsontext.Value) { + m.filters = &j m.appendfilters = nil } // Filters returns the value of the "filters" field in the mutation. -func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { +func (m *UsageCleanupTaskMutation) Filters() (r jsontext.Value, exists bool) { v := m.filters if v == nil { return @@ -43393,7 +43393,7 @@ func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) { // OldFilters returns the old "filters" field's value of the UsageCleanupTask entity. // If the UsageCleanupTask object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMessage, err error) { +func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v jsontext.Value, err error) { if !m.op.Is(OpUpdateOne) { return v, errors.New("OldFilters is only allowed on UpdateOne operations") } @@ -43407,13 +43407,13 @@ func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMe return oldValue.Filters, nil } -// AppendFilters adds jm to the "filters" field. -func (m *UsageCleanupTaskMutation) AppendFilters(jm json.RawMessage) { - m.appendfilters = append(m.appendfilters, jm...) +// AppendFilters adds j to the "filters" field. +func (m *UsageCleanupTaskMutation) AppendFilters(j jsontext.Value) { + m.appendfilters = append(m.appendfilters, j...) } // AppendedFilters returns the list of values that were appended to the "filters" field in this mutation. -func (m *UsageCleanupTaskMutation) AppendedFilters() (json.RawMessage, bool) { +func (m *UsageCleanupTaskMutation) AppendedFilters() (jsontext.Value, bool) { if len(m.appendfilters) == 0 { return nil, false } @@ -43964,7 +43964,7 @@ func (m *UsageCleanupTaskMutation) SetField(name string, value ent.Value) error m.SetStatus(v) return nil case usagecleanuptask.FieldFilters: - v, ok := value.(json.RawMessage) + v, ok := value.(jsontext.Value) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } diff --git a/backend/ent/usagecleanuptask.go b/backend/ent/usagecleanuptask.go index e3a17b5aed..b4dbf0fd34 100644 --- a/backend/ent/usagecleanuptask.go +++ b/backend/ent/usagecleanuptask.go @@ -4,6 +4,7 @@ package ent import ( "encoding/json" + "encoding/json/jsontext" "fmt" "strings" "time" @@ -25,7 +26,7 @@ type UsageCleanupTask struct { // Status holds the value of the "status" field. Status string `json:"status,omitempty"` // Filters holds the value of the "filters" field. - Filters json.RawMessage `json:"filters,omitempty"` + Filters jsontext.Value `json:"filters,omitempty"` // CreatedBy holds the value of the "created_by" field. CreatedBy int64 `json:"created_by,omitempty"` // DeletedRows holds the value of the "deleted_rows" field. diff --git a/backend/ent/usagecleanuptask_create.go b/backend/ent/usagecleanuptask_create.go index 0b1dcff55e..b6dbcb3cc8 100644 --- a/backend/ent/usagecleanuptask_create.go +++ b/backend/ent/usagecleanuptask_create.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate { } // SetFilters sets the "filters" field. -func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate { +func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate { _c.mutation.SetFilters(v) return _c } @@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert { +func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert { u.Set(usagecleanuptask.FieldFilters, v) return u } @@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne { } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne { +func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) @@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk } // SetFilters sets the "filters" field. -func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk { +func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk { return u.Update(func(s *UsageCleanupTaskUpsert) { s.SetFilters(v) }) diff --git a/backend/ent/usagecleanuptask_update.go b/backend/ent/usagecleanuptask_update.go index 604202c679..d233258bbe 100644 --- a/backend/ent/usagecleanuptask_update.go +++ b/backend/ent/usagecleanuptask_update.go @@ -4,7 +4,7 @@ package ent import ( "context" - "encoding/json" + "encoding/json/jsontext" "errors" "fmt" "time" @@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate { +func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate { _u.mutation.AppendFilters(v) return _u } @@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT } // SetFilters sets the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.SetFilters(v) return _u } // AppendFilters appends value to the "filters" field. -func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne { +func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne { _u.mutation.AppendFilters(v) return _u } diff --git a/backend/go.mod b/backend/go.mod index d5188b3ca7..b86368117f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.6 +go 1.27.0 require ( entgo.io/ent v0.14.5 @@ -24,6 +24,8 @@ require ( github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.8.0 github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 @@ -54,6 +56,8 @@ require ( golang.org/x/net v0.56.0 golang.org/x/sync v0.21.0 golang.org/x/term v0.44.0 + google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -121,12 +125,14 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -149,6 +155,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect @@ -187,10 +194,9 @@ require ( github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/sdk v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -200,8 +206,7 @@ require ( golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index cdea1a5b91..cbb257f12f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -250,8 +255,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -262,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= @@ -269,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo= github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo= @@ -281,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -307,13 +318,15 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -350,8 +363,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -386,8 +399,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -423,8 +434,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -439,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -507,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -608,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -615,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -678,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= -google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 048f2d76e9..8c856cbf61 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( @@ -61,6 +61,10 @@ const ( // 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。 const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024 +// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。 +// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。 +const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024 + type Config struct { Server ServerConfig `mapstructure:"server"` Log LogConfig `mapstructure:"log"` @@ -99,6 +103,18 @@ type Config struct { Idempotency IdempotencyConfig `mapstructure:"idempotency"` BatchImage BatchImageConfig `mapstructure:"batch_image"` ImageStorage ImageStorageConfig `mapstructure:"image_storage"` + Plugins PluginConfig `mapstructure:"plugins"` +} + +// PluginConfig 控制管理员手动上传的本地进程插件。 +// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。 +type PluginConfig struct { + DataDir string `mapstructure:"data_dir"` + AllowUnsigned bool `mapstructure:"allow_unsigned"` + TrustedPublishers map[string]string `mapstructure:"trusted_publishers"` + MaxUploadBytes int64 `mapstructure:"max_upload_bytes"` + MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"` + StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"` } type LogConfig struct { @@ -948,6 +964,8 @@ type GatewayConfig struct { TextMaxBodySize int64 `mapstructure:"text_max_body_size"` // 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大 UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"` + // 上游模型列表响应体读取上限(字节) + ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"` // 代理探测响应体读取上限(字节) ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"` // Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销) @@ -2269,6 +2287,14 @@ func setDefaults() { viper.SetDefault("pricing.update_interval_hours", 24) viper.SetDefault("pricing.hash_check_interval_minutes", 10) + // 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。 + viper.SetDefault("plugins.data_dir", "") + viper.SetDefault("plugins.allow_unsigned", false) + viper.SetDefault("plugins.trusted_publishers", map[string]string{}) + viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024)) + viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024)) + viper.SetDefault("plugins.start_timeout_seconds", 15) + // Timezone (default to Asia/Shanghai for Chinese users) viper.SetDefault("timezone", "Asia/Shanghai") @@ -2436,6 +2462,7 @@ func setDefaults() { viper.SetDefault("gateway.max_body_size", int64(256*1024*1024)) viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024)) viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes) + viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes) viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024)) viper.SetDefault("gateway.gemini_debug_response_headers", false) viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy) @@ -2625,6 +2652,15 @@ func (c *Config) Validate() error { return fmt.Errorf("security.proxy_probe.urls: %w", err) } c.Security.ProxyProbe.URLs = proxyProbeURLs + if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 { + return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824") + } + if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 { + return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648") + } + if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 { + return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120") + } if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } @@ -3236,6 +3272,9 @@ func (c *Config) Validate() error { if c.Gateway.UpstreamResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive") } + if c.Gateway.ModelsListReadMaxBytes <= 0 { + return fmt.Errorf("gateway.models_list_read_max_bytes must be positive") + } if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 { return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d8cfe820b1..a8fbba0977 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -23,6 +23,13 @@ func resetViperWithJWTSecret(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } +func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) +} + func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string @@ -553,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) { require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds) } +func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true") + + cfg, err := Load() + require.NoError(t, err) + require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP) +} + func TestLoadDefaultOpenAICompactModel(t *testing.T) { resetViperWithJWTSecret(t) @@ -1794,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 }, wantErr: "gateway.text_max_body_size", }, + { + name: "gateway models list read limit", + mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 }, + wantErr: "gateway.models_list_read_max_bytes", + }, { name: "gateway response header timeout", mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 }, diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index cd821165a4..1ed90ee940 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -105,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射 "claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型 "claude-sonnet-4-6": "claude-sonnet-4-6", - "claude-sonnet-4-5": "claude-sonnet-4-5", - "claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking", + "claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传 + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名 // Claude 详细版本 ID 映射 "claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型 - "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型 // Claude Haiku → Sonnet(无 Haiku 支持) "claude-haiku-4-5": "claude-sonnet-4-6", "claude-haiku-4-5-20251001": "claude-sonnet-4-6", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index e847e22f30..acac038ef2 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -43,6 +43,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { } } +func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "claude-sonnet-4-5": "claude-sonnet-4-5", + "claude-sonnet-4-5-thinking": "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-6", + } + for model, want := range cases { + if got := DefaultAntigravityModelMapping[model]; got != want { + t.Fatalf("expected model %q to map to %q, got %q", model, want, got) + } + } +} + func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index dc0e5b07e2..c1b3507aef 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -74,8 +74,9 @@ type channelModelPricingRequest struct { } type channelTimePricingRequest struct { - Timezone string `json:"timezone"` - Periods []channelTimePricingPeriodRequest `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodRequest `json:"periods"` } type channelTimePricingPeriodRequest struct { @@ -144,8 +145,9 @@ type channelModelPricingResponse struct { } type channelTimePricingResponse struct { - Timezone string `json:"timezone"` - Periods []channelTimePricingPeriodResponse `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only"` + Periods []channelTimePricingPeriodResponse `json:"periods"` } type channelTimePricingPeriodResponse struct { @@ -282,7 +284,11 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin Multiplier: period.Multiplier, }) } - return &channelTimePricingResponse{Timezone: value.Timezone, Periods: periods} + return &channelTimePricingResponse{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, + } } func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse { @@ -374,7 +380,11 @@ func timePricingRequestToService(value *channelTimePricingRequest) *service.Chan Multiplier: period.Multiplier, }) } - return &service.ChannelTimePricing{Timezone: value.Timezone, Periods: periods} + return &service.ChannelTimePricing{ + Timezone: value.Timezone, + WeekdaysOnly: value.WeekdaysOnly, + Periods: periods, + } } func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule { diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index 75aec7db31..2da7e02d00 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -426,7 +426,8 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { Models: []string{"gpt-5"}, BillingMode: "token", TimePricing: &channelTimePricingRequest{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []channelTimePricingPeriodRequest{{ StartTime: "09:00", EndTime: "12:00", Multiplier: 2, }}, @@ -435,6 +436,7 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { got := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone) + require.True(t, got[0].TimePricing.WeekdaysOnly) require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier) } @@ -483,7 +485,8 @@ func TestPricingToResponse_TimePricing(t *testing.T) { got := pricingToResponse(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, TimePricing: &service.ChannelTimePricing{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []service.ChannelTimePricingPeriod{{ StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25, }}, @@ -492,6 +495,7 @@ func TestPricingToResponse_TimePricing(t *testing.T) { require.NotNil(t, got.TimePricing) require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone) + require.True(t, got.TimePricing.WeekdaysOnly) require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier) } diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 696a24b419..fdab4473de 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) { response.BadRequest(c, "grok quota service is not enabled") return } + // ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。 + //nolint:staticcheck // SA4023 result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID) - if err != nil { + if err != nil { //nolint:staticcheck // SA4023 response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index d7cfc8d2a6..779e4455cd 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -34,12 +34,6 @@ type openAIAccountStateRecoverer interface { RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) } -const ( - openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" - openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" - openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" -) - // openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the // (non-refundable) reset credit has already been consumed upstream. The whole // request must stay comfortably inside the panel HTTP client timeout, otherwise @@ -493,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.ErrorFrom(c, err) return } + service.NotifyOpenAIAutoResetCredit(accountID) response.Success(c, usage) } @@ -523,6 +518,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result") return } + service.NotifyOpenAIAutoResetCredit(accountID) refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage} // A failed snapshot write leaves the previous cache intact — report it as a @@ -600,54 +596,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context()) defer cancelPost() - // Step 1 — unblocking the account is the whole point of consuming a credit - // (#3672 / #3740), so it runs FIRST and is never gated on the display cache. - // Recovery is DB-only and leaves the manual `schedulable` switch untouched. - if h.rateLimitService == nil { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return + postResult := service.RunOpenAIQuotaResetPostProcess( + postCtx, + accountID, + h.quotaService, + h.rateLimitService, + h.adminService.GetAccount, + ) + resetResponse.Quota = postResult.Quota + resetResponse.CacheRefreshed = postResult.CacheRefreshed + resetResponse.AccountStateRecovered = postResult.AccountStateRecovered + resetResponse.WarningCode = postResult.WarningCode + if postResult.Account != nil { + resetResponse.Account = dto.AccountFromService(postResult.Account) } - if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{ - InvalidateToken: true, - }); err != nil { - // Recovery failures are almost always storage-level; the remaining steps - // share that dependency, so stop here instead of compounding the failure. - slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed - response.Success(c, resetResponse) - return - } - resetResponse.AccountStateRecovered = true - - // Step 2 — refresh the reset-credit display cache. A failure here is reported - // but must not hide the recovered account row produced by step 3. - usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID) - switch { - case usageErr != nil || usage == nil: - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - default: - if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil { - slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) - resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed - } else { - resetResponse.Quota = usage - resetResponse.CacheRefreshed = true - } - } - - // Step 3 — hand back the post-recovery account row so the list drops the - // stale rate-limit badge without waiting for the next poll. - account, err := h.adminService.GetAccount(postCtx, accountID) - if err != nil { - slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err) - if resetResponse.WarningCode == "" { - resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed - } - response.Success(c, resetResponse) - return - } - resetResponse.Account = dto.AccountFromService(account) response.Success(c, resetResponse) } diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go index c0e3ca161d..b9e8dac679 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -215,7 +215,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -237,7 +237,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) require.False(t, envelope.Data.AccountStateRecovered) require.Zero(t, quota.queryCalls) require.Zero(t, adminService.calls) @@ -260,7 +260,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -285,7 +285,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.False(t, envelope.Data.CacheRefreshed) require.Nil(t, envelope.Data.Quota) @@ -307,7 +307,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing. status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.CacheRefreshed) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Quota) @@ -331,7 +331,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.Nil(t, envelope.Data.Account) } @@ -435,7 +435,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) { status, envelope := performOpenAIQuotaResetRequest(t, handler) require.Equal(t, http.StatusOK, status) - require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) require.True(t, envelope.Data.AccountStateRecovered) require.NotNil(t, envelope.Data.Account) require.Zero(t, quota.cacheCalls) diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go new file mode 100644 index 0000000000..a0967c6efe --- /dev/null +++ b/backend/internal/handler/admin/plugin_handler.go @@ -0,0 +1,257 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const pluginUISessionTTL = 30 * time.Minute + +// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。 +type PluginHandler struct { + manager *service.PluginManager +} + +func NewPluginHandler(manager *service.PluginManager) *PluginHandler { + return &PluginHandler{manager: manager} +} + +func (h *PluginHandler) List(c *gin.Context) { + plugins, err := h.manager.List(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugins) +} + +func (h *PluginHandler) Get(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Get(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Upload(c *gin.Context) { + maxBytes := h.manager.MaxUploadBytes() + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20)) + file, header, err := c.Request.FormFile("plugin") + if err != nil { + response.BadRequest(c, "请选择有效的 .s2plugin 文件") + return + } + defer func() { _ = file.Close() }() + if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") { + response.BadRequest(c, "插件包扩展名必须是 .s2plugin") + return + } + var installedBy *int64 + if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { + userID := subject.UserID + installedBy = &userID + } + plugin, err := h.manager.Install(c.Request.Context(), file, installedBy) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, plugin) +} + +type pluginEnableRequest struct { + AcceptUntested bool `json:"accept_untested"` + RolloutPercent int `json:"rollout_percent"` +} + +func (h *PluginHandler) Enable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + request := pluginEnableRequest{RolloutPercent: 100} + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "启用参数无效") + return + } + plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Disable(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + plugin, err := h.manager.Disable(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, plugin) +} + +func (h *PluginHandler) Delete(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + if err := h.manager.Delete(c.Request.Context(), id); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"message": "插件已卸载"}) +} + +func (h *PluginHandler) GetConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + configJSON, err := h.manager.GetConfig(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON) +} + +func (h *PluginHandler) SaveConfig(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + response.BadRequest(c, "插件配置必须是有效 JSON") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + response.BadRequest(c, "插件配置只能包含一个 JSON 值") + return + } + raw, err := json.Marshal(value) + if err != nil { + response.BadRequest(c, "插件配置无法序列化") + return + } + saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + c.Data(http.StatusOK, "application/json; charset=utf-8", saved) +} + +func (h *PluginHandler) Test(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + result, err := h.manager.Test(c.Request.Context(), id) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, result) +} + +func (h *PluginHandler) CreateUISession(c *gin.Context) { + id, ok := pluginIDParam(c) + if !ok { + return + } + assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL) + if err != nil { + response.ErrorFrom(c, err) + return + } + bridgeToken, err := randomPluginToken() + if err != nil { + response.InternalError(c, "创建插件 UI Bridge 失败") + return + } + response.Success(c, gin.H{ + "url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken), + "bridge_token": bridgeToken, + "ui_bridge_version": 1, + "expires_at": expires, + }) +} + +// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。 +func (h *PluginHandler) ServeUIAsset(c *gin.Context) { + token := strings.TrimSpace(c.Param("token")) + pluginID, err := h.manager.ResolveUIAssetToken(token) + if err != nil { + c.Status(http.StatusGone) + return + } + relative := strings.TrimPrefix(c.Param("path"), "/") + data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.Status(http.StatusNotFound) + return + } + c.Status(http.StatusNotFound) + return + } + contentType := mime.TypeByExtension(filepath.Ext(logicalPath)) + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "SAMEORIGIN") + // sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。 + // 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。 + c.Header("Cross-Origin-Resource-Policy", "cross-origin") + c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'") + c.Data(http.StatusOK, contentType, data) +} + +func pluginIDParam(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "插件 ID 无效") + return 0, false + } + return id, true +} + +func randomPluginToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 01a066165b..4c8cc145dc 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -382,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ModelPlazaEnabled: settings.ModelPlazaEnabled, - ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, - ModelPlazaDescription: settings.ModelPlazaDescription, + ModelPlazaEnabled: settings.ModelPlazaEnabled, + ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, + ModelPlazaDescription: settings.ModelPlazaDescription, AffiliateEnabled: settings.AffiliateEnabled, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index e2399d0891..b2c001fe73 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -347,6 +347,9 @@ type UpdateSettingsRequest struct { ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"` ModelPlazaDescription *string `json:"model_plaza_description"` + // Plugin management menu visibility switch; plugin runtime is unaffected. + PluginManagementEnabled *bool `json:"plugin_management_enabled"` + // Affiliate (邀请返利) feature switch AffiliateEnabled *bool `json:"affiliate_enabled"` @@ -438,7 +441,7 @@ func buildSettingKeyByJSONName() map[string]string { out := make(map[string]string, t.NumField()) for i := 0; i < t.NumField(); i++ { field := t.Field(i) - if field.Type.Kind() == reflect.Ptr { + if field.Type.Kind() == reflect.Pointer { continue } name, _, _ := strings.Cut(field.Tag.Get("json"), ",") @@ -1938,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ModelPlazaDescription }(), + PluginManagementEnabled: func() bool { + if req.PluginManagementEnabled != nil { + return *req.PluginManagementEnabled + } + return previousSettings.PluginManagementEnabled + }(), AffiliateEnabled: func() bool { if req.AffiliateEnabled != nil { return *req.AffiliateEnabled @@ -2357,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, - ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled, - ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth, - ModelPlazaDescription: updatedSettings.ModelPlazaDescription, + ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled, + ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth, + ModelPlazaDescription: updatedSettings.ModelPlazaDescription, + PluginManagementEnabled: updatedSettings.PluginManagementEnabled, AffiliateEnabled: updatedSettings.AffiliateEnabled, diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 0367e4cff0..13c26cf18c 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) { if err != nil { return nil, fmt.Errorf("decode ec y: %w", err) } - if !curve.IsOnCurve(x, y) { + if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移 return nil, errors.New("ec point is not on curve") } - return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上 default: return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 300eb1b1a7..a6b7a36a3c 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -284,13 +284,13 @@ func toUserSupportedModels( return out } -// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。 -func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { - if p == nil { +// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。 +func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO { + if src == nil { return nil } - intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals)) - for _, iv := range p.Intervals { + intervals := make([]userPricingIntervalDTO, 0, len(src)) + for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ MinTokens: iv.MinTokens, MaxTokens: iv.MaxTokens, @@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { PerRequestPrice: iv.PerRequestPrice, }) } + return intervals +} + +// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。 +func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { + if p == nil { + return nil + } + intervals := toUserPricingIntervals(p.Intervals) + if intervals == nil { + // 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。 + intervals = []userPricingIntervalDTO{} + } billingMode := string(p.BillingMode) if billingMode == "" { billingMode = string(service.BillingModeToken) diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index be1706fba3..6e068a4614 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -31,6 +31,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { }{ {model: "grok-4.3", platform: service.PlatformGrok}, {model: "kimi-k2-thinking", platform: service.PlatformKimi}, + {model: "k3", platform: service.PlatformKimi}, {model: "glm-5.2", platform: service.PlatformZhipu}, {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 798de2fed9..0d0c1ed40f 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -316,9 +316,10 @@ type SystemSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - ModelPlazaDescription string `json:"model_plaza_description"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaDescription string `json:"model_plaza_description"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` @@ -418,8 +419,9 @@ type PublicSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 2a270d3a0a..07840b7ea4 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -569,6 +569,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) + // 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。 + var longContextThreshold int + var longContextMultiplier float64 + if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil { + longContextThreshold = rule.Threshold + longContextMultiplier = rule.Multiplier + } h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ Result: result, @@ -583,8 +590,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, - LongContextThreshold: 200000, // Gemini 200K 阈值 - LongContextMultiplier: 2.0, // 超出部分双倍计费 + LongContextThreshold: longContextThreshold, + LongContextMultiplier: longContextMultiplier, ForceCacheBilling: forceCacheBilling, APIKeyService: h.apiKeyService, SessionID: sessionID, diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 7c0d3921eb..f7126904db 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -31,6 +31,7 @@ type AdminHandlers struct { UserAttribute *admin.UserAttributeHandler ErrorPassthrough *admin.ErrorPassthroughHandler TLSFingerprintProfile *admin.TLSFingerprintProfileHandler + Plugin *admin.PluginHandler APIKey *admin.AdminAPIKeyHandler ScheduledTest *admin.ScheduledTestHandler Channel *admin.ChannelHandler diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 0bf294d443..829ef94af4 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -17,39 +17,60 @@ import ( // - 匿名:仅非专属分组(订阅型照常展示); // - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。 type ModelPlazaHandler struct { - channelService *service.ChannelService + plazaService *service.ModelPlazaService apiKeyService *service.APIKeyService settingService *service.SettingService } // NewModelPlazaHandler 创建模型广场 handler。 func NewModelPlazaHandler( - channelService *service.ChannelService, + plazaService *service.ModelPlazaService, apiKeyService *service.APIKeyService, settingService *service.SettingService, ) *ModelPlazaHandler { return &ModelPlazaHandler{ - channelService: channelService, + plazaService: plazaService, apiKeyService: apiKeyService, settingService: settingService, } } -// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。 +// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。 type modelPlazaOfficialPricing struct { InputPrice *float64 `json:"input_price"` OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"` CacheReadPrice *float64 `json:"cache_read_price"` + // Intervals 官方长上下文阶梯,仅多档模型给出。 + Intervals []userPricingIntervalDTO `json:"intervals,omitempty"` } -// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。 +// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。 +type modelPlazaTimePricingPeriod struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` +} + +// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 +type modelPlazaTimePricing struct { + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []modelPlazaTimePricingPeriod `json:"periods"` +} + +// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。 type modelPlazaModel struct { Name string `json:"name"` Platform string `json:"platform"` Pricing *userSupportedModelPricing `json:"pricing"` OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"` + // LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。 + LongContextBasis string `json:"long_context_basis,omitempty"` + // TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。 + TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"` } // modelPlazaGroup 广场分组条目(白名单字段)。 @@ -68,9 +89,11 @@ type modelPlazaGroup struct { IsExclusive bool `json:"is_exclusive"` // 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier, // 不取分组/用户专属倍率。 - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier float64 `json:"image_rate_multiplier"` - Models []modelPlazaModel `json:"models"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + // 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。 + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` + Models []modelPlazaModel `json:"models"` } // modelPlazaResponse 广场页响应。 @@ -98,7 +121,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { return } - groups, err := h.channelService.ListPlazaGroups(c.Request.Context()) + groups, err := h.plazaService.ListGroups(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return @@ -161,27 +184,30 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo for i := range g.Models { m := &g.Models[i] models = append(models, modelPlazaModel{ - Name: m.Name, - Platform: m.Platform, - Pricing: toUserPricing(m.Pricing), - OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), + Name: m.Name, + Platform: m.Platform, + Pricing: toUserPricing(m.Pricing), + OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), + LongContextBasis: string(m.LongContextBasis), + TimePricing: toModelPlazaTimePricing(m.TimePricing), }) } dto := modelPlazaGroup{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - PeakRateEnabled: g.PeakRateEnabled, - PeakStart: g.PeakStart, - PeakEnd: g.PeakEnd, - PeakRateMultiplier: g.PeakRateMultiplier, - IsExclusive: g.IsExclusive, - ImageRateIndependent: g.ImageRateIndependent, - ImageRateMultiplier: g.ImageRateMultiplier, - Models: models, + ID: g.ID, + Name: g.Name, + Description: g.Description, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, + LongContextPricingEnabled: g.LongContextPricingEnabled, + Models: models, } if rate, ok := userRates[g.ID]; ok { dto.UserRateMultiplier = &rate @@ -189,6 +215,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo return dto } +// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。 +func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing { + if p == nil || len(p.Periods) == 0 { + return nil + } + periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods)) + for _, period := range p.Periods { + periods = append(periods, modelPlazaTimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: period.Multiplier, + }) + } + return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods} +} + // toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。 func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing { if p == nil { @@ -200,5 +242,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff CacheWritePrice: p.CacheWritePrice, CacheWrite1hPrice: p.CacheWrite1hPrice, CacheReadPrice: p.CacheReadPrice, + Intervals: toUserPricingIntervals(p.Intervals), } } diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index a7fc291ab8..ab37a6db24 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -91,7 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { "id", "name", "description", "platform", "subscription_type", "rate_multiplier", "user_rate_multiplier", "is_exclusive", "models", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", - "image_rate_independent", "image_rate_multiplier", + "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled", } { _, exists := decoded[key] require.Truef(t, exists, "plaza group DTO must expose %q", key) @@ -109,6 +109,12 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { require.Contains(t, official, "cache_read_price") _, has1h := official["cache_write_1h_price"] require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty") + _, hasOfficialIntervals := official["intervals"] + require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty") + _, hasBasis := model["long_context_basis"] + require.False(t, hasBasis, "单档模型不输出 long_context_basis") + _, hasTimePricing := model["time_pricing"] + require.False(t, hasTimePricing, "无分时时不输出 time_pricing") // 无专属倍率:user_rate_multiplier 整个字段省略 dtoNoRate := toModelPlazaGroupDTO(&g, nil) @@ -124,4 +130,94 @@ func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) { require.Nil(t, toModelPlazaOfficialPricing(nil)) } +func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) { + maxTokens := 272000 + g := service.PlazaGroup{ + ID: 3, Name: "ladder", Platform: "openai", SubscriptionType: "standard", RateMultiplier: 1, + LongContextPricingEnabled: true, + Models: []service.PlazaModel{{ + Name: "gpt-5.4", + Platform: "openai", + Pricing: &service.ChannelModelPricing{ + BillingMode: service.BillingModeToken, + InputPrice: testPtr(2.5e-6), + Intervals: []service.PricingInterval{ + {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)}, + {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)}, + }, + }, + OfficialPricing: &service.PlazaOfficialPricing{ + InputPrice: testPtr(2.5e-6), + Intervals: []service.PricingInterval{ + {MinTokens: 0, MaxTokens: &maxTokens, TierLabel: "≤272K", InputPrice: testPtr(2.5e-6)}, + {MinTokens: 272000, TierLabel: ">272K", InputPrice: testPtr(5e-6)}, + }, + }, + LongContextBasis: service.ContextPricingBasisWholeRequest, + }}, + } + + raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.Equal(t, true, decoded["long_context_pricing_enabled"]) + + model := decoded["models"].([]any)[0].(map[string]any) + require.Equal(t, "whole_request", model["long_context_basis"]) + + pricing := model["pricing"].(map[string]any) + paidTiers := pricing["intervals"].([]any) + require.Len(t, paidTiers, 2) + require.Equal(t, ">272K", paidTiers[1].(map[string]any)["tier_label"]) + + official := model["official_pricing"].(map[string]any) + officialTiers := official["intervals"].([]any) + require.Len(t, officialTiers, 2) + first := officialTiers[0].(map[string]any) + require.Equal(t, "≤272K", first["tier_label"]) + require.InDelta(t, 272000, first["max_tokens"].(float64), 0) + require.Contains(t, first, "cache_write_price", "区间 DTO 字段齐全(nil 输出 null)") +} + func testPtr(v float64) *float64 { return &v } + +func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { + g := service.PlazaGroup{ + ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1, + Models: []service.PlazaModel{{ + Name: "deepseek-chat", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }, { + Name: "deepseek-reasoner", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.56e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + } + raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + model := decoded["models"].([]any)[0].(map[string]any) + tp := model["time_pricing"].(map[string]any) + require.Equal(t, "Asia/Shanghai", tp["timezone"]) + _, hasWeekdaysOnly := tp["weekdays_only"] + require.False(t, hasWeekdaysOnly, "未开启仅工作日时字段省略") + periods := tp["periods"].([]any) + require.Len(t, periods, 1) + first := periods[0].(map[string]any) + require.Equal(t, "00:30", first["start_time"]) + require.Equal(t, "08:30", first["end_time"]) + require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12) + + weekdaysModel := decoded["models"].([]any)[1].(map[string]any) + weekdaysTP := weekdaysModel["time_pricing"].(map[string]any) + require.Equal(t, true, weekdaysTP["weekdays_only"]) +} diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 7f83f9ba90..aa2b610d70 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -89,6 +89,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } if service.IsGPTImageGenerationModel(reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint") return diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index d9da0bef71..32cea2f073 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -392,6 +392,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage) return } + if _, err := service.ValidateOpenAIServiceTierField(body); err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) previousResponseID := strings.TrimSpace(gjson.GetBytes(body, "previous_response_id").String()) if previousResponseID != "" { diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go new file mode 100644 index 0000000000..97da6f682c --- /dev/null +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -0,0 +1,94 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 非法 service_tier 必须在两个 OpenAI 端点(/v1/responses、/v1/chat/completions) +// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier +// 校验处短路,不会进入账号选择/重试。 +// +// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由 +// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler +// 测试走入真实账号选择/重试路径。 + +func newServiceTierHandlerTest(t *testing.T) *OpenAIGatewayHandler { + t.Helper() + return &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil), + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService( + &helperConcurrencyCacheStub{userSeq: []bool{true}}, + )}, + cfg: &config.Config{}, + imageLimiter: &imageConcurrencyLimiter{}, + } +} + +func runOpenAIHandlerServiceTierTest(t *testing.T, path, body string, handler func(h *OpenAIGatewayHandler, c *gin.Context)) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + groupID := int64(6401) + userID := int64(6402) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 6403, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, + User: &service.User{ID: userID, Status: service.StatusActive}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1}) + + handler(newServiceTierHandlerTest(t), c) + return rec +} + +func TestOpenAIGatewayHandlerResponses_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","input":"hi","service_tier":"turbo"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":"SPEED"}`, + `{"model":"gpt-5.5","input":"hi","service_tier":""}`, + `{"model":"gpt-5.5","input":"hi","service_tier":123}`, + `{"model":"gpt-5.5","input":"hi","service_tier":{}}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/responses", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.Responses(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} + +func TestOpenAIGatewayHandlerChatCompletions_InvalidServiceTierRejected400(t *testing.T) { + for _, body := range []string{ + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"turbo"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":"ultra"}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":""}`, + `{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"service_tier":["priority"]}`, + } { + rec := runOpenAIHandlerServiceTierTest(t, "/v1/chat/completions", body, func(h *OpenAIGatewayHandler, c *gin.Context) { + h.ChatCompletions(c) + }) + require.Equal(t, http.StatusBadRequest, rec.Code, "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid_request_error", "body=%s", body) + require.Contains(t, rec.Body.String(), "invalid service_tier", "body=%s", body) + } +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index f0785de996..d314a4f94b 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -111,8 +111,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, - ModelPlazaEnabled: settings.ModelPlazaEnabled, - ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + ModelPlazaEnabled: settings.ModelPlazaEnabled, + ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, AffiliateEnabled: settings.AffiliateEnabled, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 1eef8c0019..0e75dc9258 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -35,6 +35,7 @@ func ProvideAdminHandlers( userAttributeHandler *admin.UserAttributeHandler, errorPassthroughHandler *admin.ErrorPassthroughHandler, tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler, + pluginHandler *admin.PluginHandler, apiKeyHandler *admin.AdminAPIKeyHandler, scheduledTestHandler *admin.ScheduledTestHandler, channelHandler *admin.ChannelHandler, @@ -76,6 +77,7 @@ func ProvideAdminHandlers( UserAttribute: userAttributeHandler, ErrorPassthrough: errorPassthroughHandler, TLSFingerprintProfile: tlsFingerprintProfileHandler, + Plugin: pluginHandler, APIKey: apiKeyHandler, ScheduledTest: scheduledTestHandler, Channel: channelHandler, @@ -117,6 +119,7 @@ func ProvideGatewayHandler( func ProvideOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, + pluginManager *service.PluginManager, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, @@ -128,6 +131,7 @@ func ProvideOpenAIGatewayHandler( cfg *config.Config, coordinator *securityaudit.Coordinator, ) *OpenAIGatewayHandler { + gatewayService.SetPluginManager(pluginManager) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.securityAuditCoordinator = coordinator @@ -192,6 +196,7 @@ func ProvideHandlers( batchImageHandler *BatchImageHandler, _ *service.IdempotencyCoordinator, _ *service.IdempotencyCleanupService, + _ *service.OpenAIQuotaAutoResetService, ) *Handlers { return &Handlers{ Auth: authHandler, @@ -267,6 +272,7 @@ var ProviderSet = wire.NewSet( admin.NewUserAttributeHandler, admin.NewErrorPassthroughHandler, admin.NewTLSFingerprintProfileHandler, + admin.NewPluginHandler, admin.NewAdminAPIKeyHandler, admin.NewScheduledTestHandler, admin.NewChannelHandler, diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index 785c8af3c4..a3ea6709f7 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -255,8 +255,6 @@ const ( proxyTLSHandshakeTimeout = 5 * time.Second // clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body) clientTimeout = 10 * time.Second - // fetchAvailableModelsBodyLimit limits model-list responses to avoid unbounded memory use. - fetchAvailableModelsBodyLimit int64 = 8 << 20 ) func NewClient(proxyURL string) (*Client, error) { @@ -656,10 +654,13 @@ type FetchAvailableModelsResponse struct { // FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON // 支持 URL fallback:sandbox → daily → prod -func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) { +func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) { if c == nil || c.httpClient == nil { return nil, nil, errors.New("antigravity client is not configured") } + if bodyLimit <= 0 { + return nil, nil, errors.New("fetchAvailableModels body limit must be positive") + } reqBody := FetchAvailableModelsRequest{Project: projectID} bodyBytes, err := json.Marshal(reqBody) @@ -693,13 +694,13 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI return nil, nil, lastErr } - respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, fetchAvailableModelsBodyLimit+1)) + respBodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) _ = resp.Body.Close() // 立即关闭,避免循环内 defer 导致的资源泄漏 if err != nil { return nil, nil, fmt.Errorf("读取响应失败: %w", err) } - if int64(len(respBodyBytes)) > fetchAvailableModelsBodyLimit { - return nil, nil, fmt.Errorf("响应超过 %d 字节", fetchAvailableModelsBodyLimit) + if int64(len(respBodyBytes)) > bodyLimit { + return nil, nil, fmt.Errorf("响应超过 %d 字节", bodyLimit) } // 检查是否需要 URL 降级 diff --git a/backend/internal/pkg/antigravity/client_test.go b/backend/internal/pkg/antigravity/client_test.go index b6c2e6a502..7a525879fa 100644 --- a/backend/internal/pkg/antigravity/client_test.go +++ b/backend/internal/pkg/antigravity/client_test.go @@ -15,6 +15,8 @@ import ( "time" ) +const defaultFetchAvailableModelsBodyLimit int64 = 8 << 20 + // --------------------------------------------------------------------------- // NewAPIRequestWithURL // --------------------------------------------------------------------------- @@ -1517,7 +1519,7 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "test-token", "project-abc", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1562,6 +1564,34 @@ func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) { } } +func TestClient_FetchAvailableModels_RejectsNonPositiveBodyLimit(t *testing.T) { + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 0) + if err == nil || !strings.Contains(err.Error(), "body limit must be positive") { + t.Fatalf("非正读取上限应返回调用错误: %v", err) + } +} + +func TestClient_FetchAvailableModels_UsesConfiguredBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + withMockBaseURLs(t, []string{server.URL}) + + client := mustNewClient(t, "") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", 8) + if err == nil { + t.Fatal("响应超过配置上限时应返回错误") + } + if !strings.Contains(err.Error(), "响应超过 8 字节") { + t.Fatalf("错误应包含配置上限: %v", err) + } +} + func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -1572,7 +1602,7 @@ func TestClient_FetchAvailableModels_HTTPError_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "bad-token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("服务器返回 403 时应返回错误") } @@ -1592,7 +1622,7 @@ func TestClient_FetchAvailableModels_InvalidJSON_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("无效 JSON 响应应返回错误") } @@ -1622,7 +1652,7 @@ func TestClient_FetchAvailableModels_URLFallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 fallback 后成功: %v", err) } @@ -1650,7 +1680,7 @@ func TestClient_FetchAvailableModels_AllURLsFail_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + _, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("所有 URL 都失败时应返回错误") } @@ -1669,7 +1699,7 @@ func TestClient_FetchAvailableModels_ContextCanceled_RealCall(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, _, err := client.FetchAvailableModels(ctx, "token", "proj") + _, _, err := client.FetchAvailableModels(ctx, "token", "proj", defaultFetchAvailableModelsBodyLimit) if err == nil { t.Fatal("context 取消时应返回错误") } @@ -1686,7 +1716,7 @@ func TestClient_FetchAvailableModels_EmptyModels_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server.URL}) client := mustNewClient(t, "") - resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, rawResp, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 失败: %v", err) } @@ -1748,7 +1778,7 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) { withMockBaseURLs(t, []string{server1.URL, server2.URL}) client := mustNewClient(t, "") - resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj") + resp, _, err := client.FetchAvailableModels(context.Background(), "token", "proj", defaultFetchAvailableModelsBodyLimit) if err != nil { t.Fatalf("FetchAvailableModels 应在 404 fallback 后成功: %v", err) } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 00726f5ef7..a5137e2305 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -674,7 +674,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // Double-conversion bridge - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") // Compare key fields @@ -988,7 +988,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *te direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) @@ -1058,7 +1058,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testin direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason)) diff --git a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go index 87c4f9e0ba..69d633f0a9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go @@ -44,7 +44,7 @@ func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) { func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) { var response ChatCompletionsResponse readIssue5302Fixture(t, "nonstream_reasoning.json", &response) - nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil) + nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, nil, false, nil) require.Len(t, nonStream.Output, 2) require.Equal(t, "reasoning", nonStream.Output[0].Type) require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index e825cf36bc..fd84af7824 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -159,6 +159,20 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { return out } +// FunctionToolNames collects explicitly declared top-level function tools. +func FunctionToolNames(tools []ResponsesTool) map[string]bool { + var out map[string]bool + for _, tool := range tools { + if tool.Type == "function" && tool.Name != "" { + if out == nil { + out = make(map[string]bool) + } + out[tool.Name] = true + } + } + return out +} + // NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。 type NamespacedToolName struct { Namespace string @@ -197,6 +211,43 @@ func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { return out } +// customToolCallName restores both the exact downgraded custom-tool name and +// namespace-prefixed aliases that chat models sometimes infer from neighboring +// flattened namespace tools (for example functions__exec beside +// functions__wait). A real declared namespace child always owns its flattened +// name, and ambiguous aliases are left as ordinary function calls. +func customToolCallName(name string, customTools, functionTools map[string]bool, namespaceTools map[string]NamespacedToolName) (string, bool) { + if functionTools[name] { + return "", false + } + if customTools[name] { + return name, true + } + if _, ok := namespaceTools[name]; ok { + return "", false + } + match := "" + for customName := range customTools { + for _, namespaceTool := range namespaceTools { + if flattenNamespaceToolName(namespaceTool.Namespace, customName) != name { + continue + } + if match != "" && match != customName { + return "", false + } + match = customName + } + } + return match, match != "" +} + +func customNameForStreamTool(state *ChatCompletionsToResponsesStreamState, name string) string { + if customName, ok := customToolCallName(name, state.CustomTools, state.FunctionTools, state.NamespaceTools); ok { + return customName + } + return name +} + // HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥 // 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型 // 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配 @@ -918,6 +969,9 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { topLevel := make(map[string]bool) for _, tool := range tools { if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" { + if topLevel[tool.Name] { + return nil, fmt.Errorf("duplicate top-level executable tool name %q; this upstream cannot disambiguate duplicate names, rename one of the tools", tool.Name) + } topLevel[tool.Name] = true } } @@ -1148,7 +1202,7 @@ func extractCustomToolCallInput(arguments string) string { // toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 // 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名 // 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -1158,10 +1212,11 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str } out := &ResponsesResponse{ - ID: id, - Object: "response", - Model: model, - Status: "completed", + ID: id, + Object: "response", + Model: model, + Status: "completed", + ServiceTier: chatServiceTier(resp), } if resp == nil { out.Output = []ResponsesOutput{emptyResponsesMessageOutput()} @@ -1173,7 +1228,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, functionTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -1188,7 +1243,14 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { +func chatServiceTier(resp *ChatCompletionsResponse) string { + if resp == nil { + return "" + } + return resp.ServiceTier +} + +func chatMessageToResponsesOutput(message ChatMessage, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput reasoning := message.reasoningText() if reasoning != "" { @@ -1224,12 +1286,12 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo if strings.TrimSpace(arguments) == "" { arguments = "{}" } - if customTools[toolCall.Function.Name] { + if customName, ok := customToolCallName(toolCall.Function.Name, customTools, functionTools, namespaceTools); ok { outputs = append(outputs, ResponsesOutput{ Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customName, Input: extractCustomToolCallInput(arguments), Status: "completed", }) @@ -1360,6 +1422,7 @@ type ChatCompletionsToResponsesStreamState struct { ResponseID string Model string Created int64 + ServiceTier string // upstream Chat chunk service_tier, echoed on response events SequenceNumber int CreatedSent bool CompletedSent bool @@ -1396,6 +1459,9 @@ type ChatCompletionsToResponsesStreamState struct { // 路由回它注册的 custom 工具。 CustomTools map[string]bool + // FunctionTools is the set of explicitly declared top-level function tools. + FunctionTools map[string]bool + // ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见 // HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按 // 该项类型(且 execution=client)执行 tool search。 @@ -1488,6 +1554,9 @@ func ChatCompletionsChunkToResponsesEvents( if state.Model == "" && chunk.Model != "" { state.Model = chunk.Model } + if chunk.ServiceTier != "" { + state.ServiceTier = chunk.ServiceTier + } if chunk.Usage != nil { state.Usage = ChatUsageToResponsesUsage(chunk.Usage) } @@ -1644,6 +1713,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr Object: "response", Model: state.Model, Status: status, + ServiceTier: state.ServiceTier, Output: state.chatOutput(), Usage: state.Usage, IncompleteDetails: incompleteDetails, @@ -1659,11 +1729,12 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState) state.CreatedSent = true return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{ Response: &ResponsesResponse{ - ID: state.ResponseID, - Object: "response", - Model: state.Model, - Status: "in_progress", - Output: []ResponsesOutput{}, + ID: state.ResponseID, + Object: "response", + Model: state.Model, + Status: "in_progress", + ServiceTier: state.ServiceTier, + Output: []ResponsesOutput{}, }, })} } @@ -1796,11 +1867,11 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || len(state.FunctionTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { return nil } state.toolAnnounced[idx] = true - isCustom := state.CustomTools[stored.Function.Name] + customName, isCustom := customToolCallName(stored.Function.Name, state.CustomTools, state.FunctionTools, state.NamespaceTools) isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName state.toolIsCustom[idx] = isCustom state.toolIsToolSearch[idx] = isToolSearch @@ -1814,6 +1885,9 @@ func announceChatToolItem( // namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要 // 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。 itemName, itemNamespace := stored.Function.Name, "" + if isCustom { + itemName = customName + } if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch { state.toolNamespace[idx] = ns itemName, itemNamespace = ns.Name, ns.Namespace @@ -1883,7 +1957,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response OutputIndex: outputIndex, ItemID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, }), chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ @@ -1892,7 +1966,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response Type: "custom_tool_call", ID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, Status: "completed", }, @@ -1983,7 +2057,7 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: extractCustomToolCallInput(arguments), Status: "completed", }) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index 945900d6d6..ebf23fd9f9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -34,6 +34,60 @@ func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *test assert.Equal(t, "wait", out.Tools[1].Function.Name) } +func TestResponsesChatBridge_MixedCustomAndNamespaceToolNames(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-test", + Input: json.RawMessage(`"run pwd"`), + Tools: []ResponsesTool{ + {Type: "custom", Name: "exec", Description: "Runs a command"}, + {Type: "namespace", Name: "functions", Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object"}`)}, + }}, + }, + } + + chatReq, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chatReq.Tools, 2) + assert.Equal(t, "exec", chatReq.Tools[0].Function.Name) + assert.Equal(t, "functions__wait", chatReq.Tools[1].Function.Name) + + customTools := CustomToolNames(req.Tools) + namespaceTools := NamespaceToolNames(req.Tools) + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{ + {ID: "call_exact", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}}, + {ID: "call_wait", Function: ChatFunctionCall{Name: "functions__wait", Arguments: `{"cell_id":"1"}`}}, + {ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}}, + }}}}} + + out := ChatCompletionsResponseToResponses(resp, req.Model, customTools, FunctionToolNames(req.Tools), false, namespaceTools) + require.Len(t, out.Output, 3) + assert.Equal(t, "custom_tool_call", out.Output[0].Type) + assert.Equal(t, "exec", out.Output[0].Name) + assert.Equal(t, "pwd", out.Output[0].Input) + assert.Equal(t, "function_call", out.Output[1].Type) + assert.Equal(t, "functions", out.Output[1].Namespace) + assert.Equal(t, "wait", out.Output[1].Name) + assert.Equal(t, "custom_tool_call", out.Output[2].Type) + assert.Equal(t, "exec", out.Output[2].Name) + assert.Equal(t, "not-json", out.Output[2].Input) +} + +func TestChatCompletionsResponseToResponses_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{{ + ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + + out := ChatCompletionsResponseToResponses(resp, "deepseek-test", + map[string]bool{"exec": true}, map[string]bool{"functions__exec": true}, false, + map[string]NamespacedToolName{"functions__wait": {Namespace: "functions", Name: "wait"}}) + + require.Len(t, out.Output, 1) + assert.Equal(t, "function_call", out.Output[0].Type) + assert.Equal(t, "functions__exec", out.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, out.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) { req := &ResponsesRequest{ Model: "gpt-test", @@ -173,7 +227,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, nil, false, nil) require.Len(t, out.Output, 2) assert.Equal(t, "custom_tool_call", out.Output[0].Type) @@ -264,6 +318,68 @@ func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项") } +func TestChatCompletionsChunkToResponsesEvents_MixedCustomNamespaceAliasStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + if evt.Type == "response.output_item.added" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + } + if evt.Type == "response.custom_tool_call_input.done" { + assert.Equal(t, "exec", evt.Name) + assert.Equal(t, "not-json", evt.Input) + } + if evt.Type == "response.output_item.done" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + assert.Equal(t, "not-json", evt.Item.Input) + } + } + + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "custom_tool_call", final.Response.Output[0].Type) + assert.Equal(t, "exec", final.Response.Output[0].Name) + assert.Equal(t, "not-json", final.Response.Output[0].Input) +} + +func TestChatCompletionsChunkToResponsesEvents_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.FunctionTools = map[string]bool{"functions__exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + assert.NotEqual(t, "response.custom_tool_call_input.done", evt.Type) + } + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "function_call", final.Response.Output[0].Type) + assert.Equal(t, "functions__exec", final.Response.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, final.Response.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) { req := &ResponsesRequest{ Model: "glm-5.2", @@ -280,6 +396,18 @@ func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`) } +func TestResponsesToChatCompletionsRequest_DropsDeferredFlagWithToolSearch(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true}]}`), &req)) + + out, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + encoded, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(encoded), "defer_loading") + require.Contains(t, string(encoded), `"name":"tool_search"`) +} + // codex 只在 ResponseItem 为 tool_search_call 变体且 execution=client 时执行 // tool search;同名 function_call 会命中 ToolSearchHandler 后因 payload 不匹配 // 触发 FunctionCallError::Fatal,直接中止整个 turn,因此回程必须还原项类型。 @@ -296,7 +424,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, true, nil) require.Len(t, out.Output, 1) item := out.Output[0] @@ -328,7 +456,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa } // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nil) require.Len(t, out.Output, 1) assert.Equal(t, "function_call", out.Output[0].Type) } @@ -644,6 +772,23 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test assert.Equal(t, "tool_search", out.Tools[0].Function.Name) } +func TestResponsesToChatCompletionsRequest_RejectsDuplicateTopLevelExecutableNames(t *testing.T) { + for _, tools := range [][]ResponsesTool{ + {{Type: "custom", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "function", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "custom", Name: "exec"}, {Type: "custom", Name: "exec"}}, + } { + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: tools, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "exec") + assert.Contains(t, err.Error(), "cannot disambiguate") + } +} + // tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发, // chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。 func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) { @@ -795,7 +940,7 @@ func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testin "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nsTools) require.Len(t, out.Output, 2) item := out.Output[0] diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 37800bb918..6060d02166 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -65,7 +65,7 @@ func TestChatCompletionsResponseToResponses_SkipsInvalidFunctionArguments(t *tes }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, nil, false, nil) require.Equal(t, "incomplete", out.Status) require.Len(t, out.Output, 1) require.Equal(t, "function_call", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 7a1584b529..a57bd73f91 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -532,7 +532,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -566,7 +566,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index b7ce024e8a..b1e2c412f1 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -119,6 +119,9 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, lowered = append(lowered, raw) } } + if stripResponsesDeferredToolFlags(lowered) { + changed = true + } if changed { req["tools"] = lowered } @@ -141,6 +144,26 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, return adapter, changed, nil } +// stripResponsesDeferredToolFlags removes defer_loading only when the final +// declaration list no longer contains the built-in tool_search it requires. +func stripResponsesDeferredToolFlags(tools []any) bool { + if hasResponsesToolSearchDeclaration(tools) { + return false + } + changed := false + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if _, exists := tool["defer_loading"]; exists { + delete(tool, "defer_loading") + changed = true + } + } + return changed +} + // AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on // a follow-up request that omits the session-level tools declaration. An // explicitly present tools field, including an empty or malformed value, diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 5b0dac4356..2b3460755f 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -66,6 +66,33 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( require.Equal(t, "team__send", namespaceCall["name"]) } +func TestAdaptResponsesClientTools_RemovesDeferredFlagsWhenToolSearchIsLowered(t *testing.T) { + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + map[string]any{"type": "function", "name": "apply_patch"}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, toolSearchProxyName, requireResponsesClientToolValue[map[string]any](t, tools[0])["name"]) + require.NotContains(t, requireResponsesClientToolValue[map[string]any](t, tools[1]), "defer_loading") +} + +func TestStripResponsesDeferredToolFlags_PreservesFlagsWithBuiltInToolSearch(t *testing.T) { + tools := []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + } + + require.False(t, stripResponsesDeferredToolFlags(tools)) + require.Equal(t, true, requireResponsesClientToolValue[map[string]any](t, tools[1])["defer_loading"]) +} + func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { requestJSON := `{ "tools":[{"type":"tool_search"}], diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index a89a1b4203..d288b31bea 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -23,10 +23,11 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp } out := &ChatCompletionsResponse{ - ID: id, - Object: "chat.completion", - Created: time.Now().Unix(), - Model: model, + ID: id, + Object: "chat.completion", + Created: time.Now().Unix(), + Model: model, + ServiceTier: resp.ServiceTier, } var contentText string @@ -118,6 +119,7 @@ type ResponsesEventToChatState struct { ID string Model string Created int64 + ServiceTier string // upstream tier observed on response events; echoed on chunks SentRole bool SawToolCall bool SawText bool @@ -187,12 +189,13 @@ func FinalizeResponsesChatStream(state *ResponsesEventToChatState) []ChatComplet if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -218,6 +221,9 @@ func resToChatHandleCreated(evt *ResponsesStreamEvent, state *ResponsesEventToCh if state.Model == "" && evt.Response.Model != "" { state.Model = evt.Response.Model } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } } // Emit the role chunk. if state.SentRole { @@ -301,6 +307,9 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if evt.Response.Usage != nil { state.Usage = chatUsageFromResponsesUsage(evt.Response.Usage) } + if evt.Response.ServiceTier != "" { + state.ServiceTier = evt.Response.ServiceTier + } switch evt.Response.Status { case "incomplete": @@ -326,12 +335,13 @@ func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo if state.IncludeUsage && state.Usage != nil { chunks = append(chunks, ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, - Choices: []ChatChunkChoice{}, - Usage: state.Usage, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, + Choices: []ChatChunkChoice{}, + Usage: state.Usage, }) } @@ -401,10 +411,11 @@ func completionDetailsFromResponses(src *ResponsesOutputTokensDetails) *ChatToke func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatCompletionsChunk { return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: delta, @@ -416,10 +427,11 @@ func makeChatDeltaChunk(state *ResponsesEventToChatState, delta ChatDelta) ChatC func makeChatFinishChunk(state *ResponsesEventToChatState, finishReason string) ChatCompletionsChunk { empty := "" return ChatCompletionsChunk{ - ID: state.ID, - Object: "chat.completion.chunk", - Created: state.Created, - Model: state.Model, + ID: state.ID, + Object: "chat.completion.chunk", + Created: state.Created, + Model: state.Model, + ServiceTier: state.ServiceTier, Choices: []ChatChunkChoice{{ Index: 0, Delta: ChatDelta{Content: &empty}, diff --git a/backend/internal/pkg/apicompat/service_tier_passthrough_test.go b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go new file mode 100644 index 0000000000..6c8dd0205c --- /dev/null +++ b/backend/internal/pkg/apicompat/service_tier_passthrough_test.go @@ -0,0 +1,175 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// 上游响应中的 service_tier 必须如实回传,不被重写/丢弃: +// 非流式(ResponsesResponse → ChatCompletionsResponse)与流式 chunk 均覆盖。 + +func TestResponsesToChatCompletions_PreservesUpstreamServiceTier(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_1", + Object: "response", + Model: "gpt-5.5", + Status: "completed", + ServiceTier: "priority", + Output: []ResponsesOutput{{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{{ + Type: "output_text", + Text: "hi", + }}, + }}, + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + } + + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Equal(t, "priority", chat.ServiceTier) + + // 序列化后字段仍在(omitempty 不丢非空值)。 + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"priority"`) +} + +func TestResponsesToChatCompletions_OmitsMissingServiceTier(t *testing.T) { + resp := &ResponsesResponse{ID: "resp_1", Model: "gpt-5.5", Status: "completed"} + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Empty(t, chat.ServiceTier) + raw, err := json.Marshal(chat) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func TestResponsesEventToChatChunks_PreservesUpstreamServiceTier(t *testing.T) { + state := NewResponsesEventToChatState() + state.IncludeUsage = true + + created := &ResponsesStreamEvent{Type: "response.created"} + require.NoError(t, json.Unmarshal([]byte(`{"type":"response.created","response":{"id":"resp_s1","model":"gpt-5.5","service_tier":"priority","status":"in_progress"}}`), created)) + + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } + + // 后续 delta chunk 继续携带(OpenAI 流式 chunk 的 service_tier 语义)。 + delta := &ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "hi"} + chunks = ResponsesEventToChatChunks(delta, state) + require.NotEmpty(t, chunks) + require.Equal(t, "priority", chunks[0].ServiceTier) + + // 终止事件同样携带。 + completed := &ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{ + ID: "resp", Model: "gpt-5.5", Status: "completed", + Usage: &ResponsesUsage{InputTokens: 1, OutputTokens: 1}, + }} + chunks = ResponsesEventToChatChunks(completed, state) + require.NotEmpty(t, chunks) + for _, chunk := range chunks { + require.Equal(t, "priority", chunk.ServiceTier) + } +} + +func TestResponsesEventToChatChunks_NoServiceTierStaysClean(t *testing.T) { + state := NewResponsesEventToChatState() + created := &ResponsesStreamEvent{Type: "response.created", Response: &ResponsesResponse{ID: "resp", Model: "gpt-5.5"}} + chunks := ResponsesEventToChatChunks(created, state) + require.NotEmpty(t, chunks) + require.Empty(t, chunks[0].ServiceTier) + raw, err := json.Marshal(chunks[0]) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +// 上游 JSON 反序列化时 service_tier 进入 ResponsesResponse(缓冲桥读取链路)。 +func TestResponsesResponse_UnmarshalPreservesServiceTier(t *testing.T) { + var resp ResponsesResponse + require.NoError(t, json.Unmarshal([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.5","status":"completed","service_tier":"flex","output":[]}`), &resp)) + require.Equal(t, "flex", resp.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 反向转换(Chat-only fallback):CC 响应/流 chunk 的 service_tier 保留到 +// Responses 形态,客户端与计费都能拿到上游回显。 +// --------------------------------------------------------------------------- + +func TestChatCompletionsResponseToResponses_PreservesServiceTier(t *testing.T) { + cc := &ChatCompletionsResponse{ + ID: "chatcmpl-1", + Model: "gpt-5.5", + ServiceTier: "default", + Choices: []ChatChoice{{ + Index: 0, + Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}, + FinishReason: "stop", + }}, + Usage: &ChatUsage{PromptTokens: 1, CompletionTokens: 1, TotalTokens: 2}, + } + resp := ChatCompletionsResponseToResponses(cc, "gpt-5.5", nil, nil, false, nil) + require.Equal(t, "default", resp.ServiceTier) + + raw, err := json.Marshal(resp) + require.NoError(t, err) + require.Contains(t, string(raw), `"service_tier":"default"`) +} + +func TestChatCompletionsResponseToResponses_NilRespOmitsServiceTier(t *testing.T) { + resp := ChatCompletionsResponseToResponses(nil, "gpt-5.5", nil, nil, false, nil) + require.Empty(t, resp.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_PreservesServiceTier(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ + ID: "chatcmpl-2", + Model: "gpt-5.5", + ServiceTier: "flex", + Choices: []ChatChunkChoice{{ + Index: 0, + Delta: ChatDelta{Content: strPtr("hi")}, + }}, + } + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + require.NotEmpty(t, events) + + // response.created 携带 service_tier。 + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.NotNil(t, created.Response) + require.Equal(t, "flex", created.Response.ServiceTier) + + // 终止事件同样携带。 + final := FinalizeChatCompletionsResponsesStream(state) + completed := findEvent(final, "response.completed") + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.Equal(t, "flex", completed.Response.ServiceTier) +} + +func TestChatCompletionsChunkToResponsesEvents_NoTierStaysClean(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("gpt-5.5") + chunk := &ChatCompletionsChunk{ID: "chatcmpl-3", Model: "gpt-5.5"} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + created := findEvent(events, "response.created") + require.NotNil(t, created) + require.Empty(t, created.Response.ServiceTier) + raw, err := json.Marshal(created) + require.NoError(t, err) + require.NotContains(t, string(raw), "service_tier") +} + +func findEvent(events []ResponsesStreamEvent, eventType string) *ResponsesStreamEvent { + for i := range events { + if events[i].Type == eventType { + return &events[i] + } + } + return nil +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b96d16b911..7ed9133e1b 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -155,12 +155,26 @@ func AnthropicStopReasonString(p *string) string { return *p } +// AnthropicPromptTokensDetails holds OpenAI-compatible prompt token details +// occasionally included by Anthropic-compatible providers. +type AnthropicPromptTokensDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` +} + // AnthropicUsage holds token counts in Anthropic format. type AnthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` + // Anthropic-compatible providers can also expose their native OpenAI-style + // total/cache fields. Preserve them so callers can normalize provider totals + // into Anthropic's mutually-exclusive billing buckets. + PromptTokens int `json:"prompt_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + PromptTokensDetails *AnthropicPromptTokensDetails `json:"prompt_tokens_details,omitempty"` + PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens *int `json:"prompt_cache_miss_tokens,omitempty"` } // --------------------------------------------------------------------------- @@ -344,12 +358,13 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error { // ResponsesResponse is the non-streaming response from POST /v1/responses. type ResponsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` // "response" - Model string `json:"model"` - Status string `json:"status"` // "completed" | "incomplete" | "failed" - Output []ResponsesOutput `json:"output"` - Usage *ResponsesUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` // "response" + Model string `json:"model"` + Status string `json:"status"` // "completed" | "incomplete" | "failed" + Output []ResponsesOutput `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` // upstream tier, echoed back verbatim // incomplete_details is present when status="incomplete" IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"` diff --git a/backend/internal/pkg/servertiming/http.go b/backend/internal/pkg/servertiming/http.go index e326e24302..2ad8a04ae1 100644 --- a/backend/internal/pkg/servertiming/http.go +++ b/backend/internal/pkg/servertiming/http.go @@ -53,10 +53,10 @@ func Do(client *http.Client, req *http.Request) (*http.Response, error) { client = http.DefaultClient } if req == nil || !Active(req.Context()) { - return client.Do(req) + return client.Do(req) //nolint:gosec // G704: 通用埋点包装器,不构造 URL;请求由调用方构造,SSRF 信任边界在调用方 } startedAt := time.Now() - response, err := client.Do(req) + response, err := client.Do(req) //nolint:gosec // G704: 同上 RecordDependency(req.Context(), dependencyModule(req), startedAt, time.Now()) return response, err } diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index 44e4772d44..39e1a2c8b7 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -19,7 +19,7 @@ const ( // repository and service layers build their own client identity from it, so // one bump here covers OAuth traffic and billing probes together. // Keep in sync with https://x.ai/cli/stable. - CLIClientVersion = "0.2.114" + CLIClientVersion = "0.2.120" // billingCLIUserAgent is the legacy pager/shell UA used by billing probes. // Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA). billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" diff --git a/backend/internal/pkg/xai/cli_identity_test.go b/backend/internal/pkg/xai/cli_identity_test.go index 521df7483d..2057b9effd 100644 --- a/backend/internal/pkg/xai/cli_identity_test.go +++ b/backend/internal/pkg/xai/cli_identity_test.go @@ -41,7 +41,7 @@ func TestApplyCLIProxyHeaders(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") ApplyCLIProxyHeaders(req) @@ -56,12 +56,12 @@ func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) { req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") ApplyCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("x-grok-client-identifier")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) } diff --git a/backend/internal/repository/channel_monitor_v2_aggregation.go b/backend/internal/repository/channel_monitor_v2_aggregation.go index 6b91a8132b..6ad2bbf9b3 100644 --- a/backend/internal/repository/channel_monitor_v2_aggregation.go +++ b/backend/internal/repository/channel_monitor_v2_aggregation.go @@ -247,18 +247,28 @@ WITH dedup AS ( FROM ops_error_logs WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL ) - SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text)) - date_trunc('minute', created_at) AS bucket_start, - lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform, - COALESCE(group_id, 0) AS group_id, - COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model, - user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code, - COALESCE(upstream_status_code, 0) AS upstream_status_code, - lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text, - (CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END - OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected, - CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts + SELECT DISTINCT ON (COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text)) + date_trunc('minute', current_error.created_at) AS bucket_start, + -- Composite groups are a routing layer: resolve the concrete account + -- platform (mirrors usageLogEffectivePlatformExpr on the usage side) so + -- error facts share the usage facts' platform key. Without this, composite + -- group errors aggregate under platform 'composite', which is never an + -- enabled config platform, and are filtered out of every monitor v2 query. + lower(CASE + WHEN g.platform = 'composite' THEN COALESCE(NULLIF(TRIM(a.platform)), NULLIF(NULLIF(lower(TRIM(current_error.platform)), ''), 'composite'), 'unknown') + ELSE COALESCE(NULLIF(TRIM(current_error.platform), ''), 'unknown') + END) AS platform, + COALESCE(current_error.group_id, 0) AS group_id, + COALESCE(NULLIF(TRIM(current_error.requested_model), ''), NULLIF(TRIM(current_error.model), ''), 'unknown') AS model, + current_error.user_id, current_error.error_type, current_error.error_owner, COALESCE(current_error.status_code, 0) AS status_code, + COALESCE(current_error.upstream_status_code, 0) AS upstream_status_code, + lower(CONCAT_WS(' ', current_error.error_type, current_error.error_source, current_error.error_message, current_error.upstream_error_message, current_error.upstream_error_detail, current_error.error_body)) AS text, + (CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) > 0 ELSE FALSE END + OR current_error.error_owner = 'provider' OR current_error.upstream_status_code IS NOT NULL) AS upstream_affected, + CASE WHEN jsonb_typeof(current_error.upstream_errors) = 'array' THEN jsonb_array_length(current_error.upstream_errors) ELSE 0 END AS upstream_attempts FROM ops_error_logs current_error + LEFT JOIN groups g ON g.id = current_error.group_id + LEFT JOIN accounts a ON a.id = current_error.account_id WHERE ( (NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2) OR ( @@ -269,7 +279,7 @@ WITH dedup AS ( ) AND NOT current_error.is_count_tokens AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy') - ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC + ORDER BY COALESCE(NULLIF(current_error.request_id, ''), 'error:' || current_error.id::text), current_error.created_at DESC, current_error.id DESC ), classified AS ( SELECT *, CASE -- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles. diff --git a/backend/internal/repository/channel_monitor_v2_repo_test.go b/backend/internal/repository/channel_monitor_v2_repo_test.go index 14b54eb1fc..55c9a96460 100644 --- a/backend/internal/repository/channel_monitor_v2_repo_test.go +++ b/backend/internal/repository/channel_monitor_v2_repo_test.go @@ -101,12 +101,23 @@ func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) require.Contains(t, query, "candidate_ids") require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2") require.Contains(t, query, "upstream_affected_requests") - require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0") + require.Contains(t, query, "jsonb_array_length(current_error.upstream_errors) > 0") // request_id dedup must be time-bounded (no full-history scan). require.Contains(t, query, "interval '90 minutes'") require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'") } +func TestChannelMonitorV2ErrorAggregationResolvesCompositePlatform(t *testing.T) { + query := strings.ToLower(channelMonitorV2ErrorAggregationSQL) + // Composite groups are a routing layer: error facts must resolve the concrete + // account platform (joining groups/accounts) so they aggregate under the same + // platform key as usage facts instead of the never-enabled 'composite' platform. + require.Contains(t, query, "g.platform = 'composite'") + require.Contains(t, query, "left join groups g on g.id = current_error.group_id") + require.Contains(t, query, "left join accounts a on a.id = current_error.account_id") + require.Contains(t, query, "a.platform") +} + func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) { for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} { require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)") diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index ead61da3b2..66a30dab0e 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -1,7 +1,10 @@ package repository import ( + "context" + "crypto/x509" "net/http" + "net/http/httptest" "net/url" "testing" "time" @@ -19,6 +22,16 @@ func http2KeepAliveTestPoolSettings() poolSettings { } } +// requireHTTP2Configured 断言 http2 已显式挂到 http.Transport 上。 +// x/net/http2 在 go1.27 && !http2legacy 下是标准库 HTTP/2 的包装:ConfigureTransports 通过 +// Transport.RegisterProtocol("http/2") 注册配置并打开 Protocols.HTTP2(TLSNextProto 不承载 h2 入口), +// ReadIdleTimeout/PingTimeout 在建连时映射为 http.HTTP2Config.SendPingTimeout/PingTimeout。 +func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { + t.Helper() + require.NotNil(t, tr.Protocols, msg) + require.True(t, tr.Protocols.HTTP2(), msg) +} + // Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: // 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的 // http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。 @@ -34,7 +47,7 @@ func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") - require.NotNil(t, tr.TLSNextProto["h2"], "http2 必须已挂到底层 http.Transport 上") + requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") } // openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 @@ -42,17 +55,45 @@ func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2") - require.NotNil(t, tr.TLSNextProto["h2"], "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") + requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } // 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, -// 构建时 TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 +// 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) require.NoError(t, err) + require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") } +// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: +// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。 +func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) + require.NoError(t, err) + defer tr.CloseIdleConnections() + require.NotNil(t, tr.TLSClientConfig) + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + tr.TLSClientConfig.RootCAs = roots + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := tr.RoundTrip(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2") +} + // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { @@ -62,6 +103,6 @@ func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testi tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2) - require.NotNil(t, tr.TLSNextProto["h2"], "经代理的 openai_h2 也必须启用 http2 keepalive") + requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive") require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index ac0c064b20..3c7840dc47 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -228,16 +229,16 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/"+endpoint, nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") resp, err := svc.Do(req, "", accountID, 1) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, capturedHeaders.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), capturedHeaders.Get("User-Agent")) }) } } @@ -454,67 +455,67 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "legacy-client/1.0") applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121-alpha.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.121-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent("0.2.121-alpha.1"), req.Header.Get("User-Agent")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.121\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-Injected")) }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.119") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.120-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) // Every entry sits above the pinned minimum, so a rejection here can only be // caused by the malformed semver and never by the version being too old. for _, version := range []string{ - "0.2.0115", - "0.2.115-alpha..1", + "0.2.0121", + "0.2.121-alpha..1", "0.3", "1", - "0.2.115+build.1", + "0.2.121+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -523,8 +524,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) + require.Equal(t, xai.CLIClientVersion, req.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), req.Header.Get("User-Agent")) }) } @@ -532,13 +533,13 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) require.NoError(t, err) - req.Header.Set("User-Agent", "sub2api-grok/1.0") + req.Header.Set("User-Agent", "direct-api-client/1.0") applyGrokCLIProxyHeaders(req) require.Empty(t, req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent")) + require.Equal(t, "direct-api-client/1.0", req.Header.Get("User-Agent")) }) } diff --git a/backend/internal/repository/plugin_repo.go b/backend/internal/repository/plugin_repo.go new file mode 100644 index 0000000000..4c942a10d9 --- /dev/null +++ b/backend/internal/repository/plugin_repo.go @@ -0,0 +1,335 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type pluginRepository struct { + db *sql.DB +} + +func NewPluginRepository(db *sql.DB) service.PluginRepository { + return &pluginRepository{db: db} +} + +func (r *pluginRepository) List(ctx context.Context) ([]*service.PluginInstallation, error) { + rows, err := r.db.QueryContext(ctx, pluginSelectSQL+` ORDER BY installed_at DESC, id DESC`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + plugins := make([]*service.PluginInstallation, 0) + for rows.Next() { + plugin, scanErr := scanPlugin(rows) + if scanErr != nil { + return nil, scanErr + } + plugins = append(plugins, plugin) + } + if err := rows.Err(); err != nil { + return nil, err + } + for _, plugin := range plugins { + bindings, bindingErr := r.listBindings(ctx, plugin.ID) + if bindingErr != nil { + return nil, bindingErr + } + plugin.Bindings = bindings + } + return plugins, nil +} + +func (r *pluginRepository) GetByID(ctx context.Context, id int64) (*service.PluginInstallation, error) { + plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE id = $1`, id)) + if err != nil { + return nil, err + } + plugin.Bindings, err = r.listBindings(ctx, plugin.ID) + return plugin, err +} + +func (r *pluginRepository) GetByKey(ctx context.Context, key string) (*service.PluginInstallation, error) { + plugin, err := scanPlugin(r.db.QueryRowContext(ctx, pluginSelectSQL+` WHERE plugin_key = $1`, key)) + if err != nil { + return nil, err + } + plugin.Bindings, err = r.listBindings(ctx, plugin.ID) + return plugin, err +} + +func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginInstallation, bindings []service.PluginBinding) (*service.PluginInstallation, error) { + manifestJSON, err := json.Marshal(plugin.Manifest) + if err != nil { + return nil, fmt.Errorf("序列化插件清单: %w", err) + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + row := tx.QueryRowContext(ctx, ` + INSERT INTO sub2api_plugin_installations ( + plugin_key, name, version, description, author, manifest, artifact_data, + artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, last_error, installed_by, installed_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13, '', $14, NOW(), NOW()) + ON CONFLICT (plugin_key) DO UPDATE SET + name = EXCLUDED.name, + version = EXCLUDED.version, + description = EXCLUDED.description, + author = EXCLUDED.author, + manifest = EXCLUDED.manifest, + artifact_data = EXCLUDED.artifact_data, + artifact_path = EXCLUDED.artifact_path, + install_path = EXCLUDED.install_path, + binary_path = EXCLUDED.binary_path, + binary_sha256 = EXCLUDED.binary_sha256, + signature_status = EXCLUDED.signature_status, + state = EXCLUDED.state, + last_error = '', + installed_by = EXCLUDED.installed_by, + installed_at = NOW(), + enabled_at = NULL, + updated_at = NOW() + WHERE sub2api_plugin_installations.state IN ('disabled', 'error', 'incompatible') + AND NOT EXISTS ( + SELECT 1 FROM sub2api_plugin_bindings b + WHERE b.plugin_id = sub2api_plugin_installations.id AND b.enabled = TRUE + ) + RETURNING id + `, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifestJSON, plugin.ArtifactData, + plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256, + plugin.SignatureStatus, plugin.State, plugin.InstalledBy) + var id int64 + if err := row.Scan(&id); err != nil { + if err == sql.ErrNoRows { + return nil, service.ErrPluginStateChanged + } + return nil, err + } + if err := replacePluginBindings(ctx, tx, id, bindings); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return r.GetByID(ctx, id) +} + +func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, error) { + var artifact []byte + err := r.db.QueryRowContext(ctx, `SELECT artifact_data FROM sub2api_plugin_installations WHERE id = $1`, id).Scan(&artifact) + return artifact, err +} + +func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM sub2api_plugin_installations p + WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled') + AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) + `, id, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = 'starting', last_error = '', updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $2 AND state = $3 AND state <> 'starting' + `, id, binarySHA256, expectedState) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations p + SET state = 'enabled', last_error = '', enabled_at = COALESCE(enabled_at, NOW()), updated_at = NOW() + WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.config_encrypted = $3 + AND EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE) + `, id, binarySHA256, configEncrypted) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $5 AND state = $6 + `, id, state, lastError, enabledAt, expectedBinarySHA256, expectedState) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET config_encrypted = $2, updated_at = NOW() + WHERE id = $1 AND binary_sha256 = $3 + `, id, encrypted, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + return nil +} + +func (r *pluginRepository) UpdateBindingsAndState( + ctx context.Context, + pluginID int64, + bindings []service.PluginBinding, + state string, + lastError string, + enabledAt *time.Time, + expectedState string, + expectedBinarySHA256 string, +) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, ` + UPDATE sub2api_plugin_installations + SET state = $2, last_error = $3, enabled_at = $4, updated_at = NOW() + WHERE id = $1 AND ($5 = '' OR state = $5) AND binary_sha256 = $6 + `, pluginID, state, lastError, enabledAt, expectedState, expectedBinarySHA256) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return service.ErrPluginStateChanged + } + if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil { + return err + } + return tx.Commit() +} + +type pluginBindingExecutor interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +func replacePluginBindings(ctx context.Context, executor pluginBindingExecutor, pluginID int64, bindings []service.PluginBinding) error { + if _, err := executor.ExecContext(ctx, `DELETE FROM sub2api_plugin_bindings WHERE plugin_id = $1`, pluginID); err != nil { + return err + } + for _, binding := range bindings { + if _, err := executor.ExecContext(ctx, ` + INSERT INTO sub2api_plugin_bindings ( + plugin_id, capability, platform, account_type, enabled, rollout_percent, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + `, pluginID, binding.Capability, binding.Platform, binding.AccountType, binding.Enabled, binding.RolloutPercent); err != nil { + return err + } + } + return nil +} + +const pluginSelectSQL = ` + SELECT id, plugin_key, name, version, description, author, manifest, + artifact_path, install_path, binary_path, binary_sha256, + signature_status, state, config_encrypted, last_error, + installed_by, installed_at, enabled_at, updated_at + FROM sub2api_plugin_installations` + +type pluginScanner interface { + Scan(dest ...any) error +} + +func scanPlugin(scanner pluginScanner) (*service.PluginInstallation, error) { + plugin := &service.PluginInstallation{} + var manifestJSON []byte + if err := scanner.Scan( + &plugin.ID, &plugin.PluginKey, &plugin.Name, &plugin.Version, &plugin.Description, + &plugin.Author, &manifestJSON, &plugin.ArtifactPath, &plugin.InstallPath, + &plugin.BinaryPath, &plugin.BinarySHA256, &plugin.SignatureStatus, &plugin.State, + &plugin.ConfigEncrypted, &plugin.LastError, &plugin.InstalledBy, &plugin.InstalledAt, + &plugin.EnabledAt, &plugin.UpdatedAt, + ); err != nil { + return nil, err + } + if err := json.Unmarshal(manifestJSON, &plugin.Manifest); err != nil { + return nil, fmt.Errorf("解析插件清单: %w", err) + } + return plugin, nil +} + +func (r *pluginRepository) listBindings(ctx context.Context, pluginID int64) ([]service.PluginBinding, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, plugin_id, capability, platform, account_type, enabled, + rollout_percent, created_at, updated_at + FROM sub2api_plugin_bindings WHERE plugin_id = $1 ORDER BY id + `, pluginID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + bindings := make([]service.PluginBinding, 0) + for rows.Next() { + var binding service.PluginBinding + if err := rows.Scan(&binding.ID, &binding.PluginID, &binding.Capability, &binding.Platform, + &binding.AccountType, &binding.Enabled, &binding.RolloutPercent, + &binding.CreatedAt, &binding.UpdatedAt); err != nil { + return nil, err + } + bindings = append(bindings, binding) + } + return bindings, rows.Err() +} + +var _ service.PluginRepository = (*pluginRepository)(nil) diff --git a/backend/internal/repository/plugin_repo_integration_test.go b/backend/internal/repository/plugin_repo_integration_test.go new file mode 100644 index 0000000000..c81f5d3257 --- /dev/null +++ b/backend/internal/repository/plugin_repo_integration_test.go @@ -0,0 +1,70 @@ +//go:build integration + +package repository + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestPluginRepositoryLifecycleIsAtomicAndOptimistic(t *testing.T) { + ctx := context.Background() + repo := &pluginRepository{db: integrationDB} + pluginKey := "local.test.repository-" + strings.ToLower(time.Now().Format("150405.000000000")) + defer func() { + _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE plugin_key = $1`, pluginKey) + }() + + manifest := service.PluginManifest{SchemaVersion: 1, ID: pluginKey, Name: "测试插件", Version: "1.0.0"} + first := &service.PluginInstallation{ + PluginKey: pluginKey, Name: "测试插件", Version: "1.0.0", Manifest: manifest, + ArtifactData: []byte("first-package"), ArtifactPath: "/tmp/first.s2plugin", + InstallPath: "/tmp/first", BinaryPath: "/tmp/first/plugin", + BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, + State: service.PluginStateDisabled, + } + bindings := []service.PluginBinding{{ + Capability: service.PluginCapabilityOpenAIOAuthOutbound, + Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth, + RolloutPercent: 100, + }} + installed, err := repo.Install(ctx, first, bindings) + require.NoError(t, err) + artifact, err := repo.GetArtifact(ctx, installed.ID) + require.NoError(t, err) + require.Equal(t, first.ArtifactData, artifact) + + require.NoError(t, repo.BeginEnable(ctx, installed.ID, first.BinarySHA256, service.PluginStateDisabled)) + bindings[0].Enabled = true + now := time.Now() + require.NoError(t, repo.UpdateBindingsAndState( + ctx, installed.ID, bindings, service.PluginStateEnabled, "", &now, + service.PluginStateStarting, first.BinarySHA256, + )) + + second := *first + second.Version = "1.1.0" + second.Manifest.Version = second.Version + second.BinarySHA256 = strings.Repeat("b", 64) + second.ArtifactData = []byte("second-package") + _, err = repo.Install(ctx, &second, bindings) + require.ErrorIs(t, err, service.ErrPluginStateChanged) + + bindings[0].Enabled = false + require.NoError(t, repo.UpdateBindingsAndState( + ctx, installed.ID, bindings, service.PluginStateDisabled, "", nil, "", first.BinarySHA256, + )) + replaced, err := repo.Install(ctx, &second, bindings) + require.NoError(t, err) + require.Equal(t, installed.ID, replaced.ID) + + err = repo.Delete(ctx, replaced.ID, first.BinarySHA256) + require.True(t, errors.Is(err, service.ErrPluginStateChanged)) + require.NoError(t, repo.Delete(ctx, replaced.ID, second.BinarySHA256)) +} diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index c69f374d41..a67c93c345 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -780,7 +780,7 @@ func (s usageLogScannerStub) Scan(dest ...any) error { } for i := range dest { dv := reflect.ValueOf(dest[i]) - if dv.Kind() != reflect.Ptr { + if dv.Kind() != reflect.Pointer { return fmt.Errorf("dest[%d] is not pointer", i) } dv.Elem().Set(reflect.ValueOf(s.values[i])) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 34c1a1b9d2..e6904f833c 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -96,6 +96,7 @@ var ProviderSet = wire.NewSet( NewUserGroupRateRepository, NewErrorPassthroughRepository, NewTLSFingerprintProfileRepository, + NewPluginRepository, NewChannelRepository, NewChannelMonitorRepository, NewChannelMonitorV2Repository, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 2bca68d3be..d1641490c4 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -994,6 +994,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", + "plugin_management_enabled": false, "risk_control_enabled": false, "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, @@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", + "plugin_management_enabled": false, "risk_control_enabled": false, "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index bb5d99abe2..df2a3940ce 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -53,6 +53,9 @@ var requiredCSPDirectiveValues = []struct { directive string value string }{ + // 插件配置 UI 使用同源 iframe;目标响应仍必须显式放开 X-Frame-Options, + // 因此这里只允许 'self' 不会使其他默认 DENY 的管理/API 页面可被嵌入。 + {"frame-src", "'self'"}, {"script-src", CloudflareInsightsDomain}, {"script-src", TencentCaptchaDomain}, {"frame-src", TencentCaptchaDomain}, @@ -196,35 +199,41 @@ func directiveHasValue(policy, directive, value string) bool { // addToDirective adds a value to a specific CSP directive. // If the directive doesn't exist, it will be added after default-src. func addToDirective(policy, directive, value string) string { - // Find the directive in the policy - directivePrefix := directive + " " - idx := strings.Index(policy, directivePrefix) - - if idx == -1 { - // Directive not found, add it after default-src or at the beginning - defaultSrcIdx := strings.Index(policy, "default-src ") - if defaultSrcIdx != -1 { - // Find the end of default-src directive (next semicolon) - endIdx := strings.Index(policy[defaultSrcIdx:], ";") - if endIdx != -1 { - insertPos := defaultSrcIdx + endIdx + 1 - // Insert new directive after default-src - return policy[:insertPos] + " " + directive + " 'self' " + value + ";" + policy[insertPos:] - } - } - // Fallback: prepend the directive - return directive + " 'self' " + value + "; " + policy + if end, ok := cspDirectiveEnd(policy, directive); ok { + return policy[:end] + " " + value + policy[end:] } - - // Find the end of this directive (next semicolon or end of string) - endIdx := strings.Index(policy[idx:], ";") - - if endIdx == -1 { - // No semicolon found, directive goes to end of string - return policy + " " + value + trimmed := strings.TrimSpace(policy) + if trimmed == "" { + return newCSPDirective(directive, value) } + if !strings.HasSuffix(trimmed, ";") { + trimmed += ";" + } + return trimmed + " " + newCSPDirective(directive, value) +} - // Insert value before the semicolon - insertPos := idx + endIdx - return policy[:insertPos] + " " + value + policy[insertPos:] +func cspDirectiveEnd(policy, directive string) (int, bool) { + start := 0 + for start <= len(policy) { + end := len(policy) + if relativeEnd := strings.IndexByte(policy[start:], ';'); relativeEnd >= 0 { + end = start + relativeEnd + } + fields := strings.Fields(policy[start:end]) + if len(fields) > 0 && fields[0] == directive { + return end, true + } + if end == len(policy) { + break + } + start = end + 1 + } + return 0, false +} + +func newCSPDirective(directive, value string) string { + if value == "'self'" { + return directive + " 'self';" + } + return directive + " 'self' " + value + ";" } diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index cb355e0c4d..cdd169c154 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -132,6 +132,25 @@ func TestSecurityHeaders(t *testing.T) { assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource)) }) + t.Run("old_custom_policy_dynamically_allows_same_origin_frames", func(t *testing.T) { + cfg := config.CSPConfig{ + Enabled: true, + Policy: "default-src 'self'; frame-src https://checkout.example.com", + } + middleware := SecurityHeaders(cfg, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/admin/plugins", nil) + + middleware(c) + + csp := w.Header().Get("Content-Security-Policy") + assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(csp, "frame-src", "https://checkout.example.com")) + assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options")) + }) + t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) { cfg := config.CSPConfig{ Enabled: true, @@ -298,6 +317,21 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Contains(t, enhanced, CloudflareInsightsDomain) }) + t.Run("allows_only_same_origin_plugin_frames", func(t *testing.T) { + policy := "default-src 'self'; frame-src https://checkout.example.com" + enhanced := enhanceCSPPolicy(policy) + + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "https://checkout.example.com")) + assert.NotContains(t, enhanced, "frame-src *") + }) + + t.Run("adds_same_origin_frame_source_once_when_directive_is_missing", func(t *testing.T) { + enhanced := enhanceCSPPolicy("default-src 'self'; script-src 'self'") + + assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", "'self'")) + }) + t.Run("does_not_duplicate_nonce_placeholder", func(t *testing.T) { policy := "default-src 'self'; script-src 'self' __CSP_NONCE__" enhanced := enhanceCSPPolicy(policy) @@ -449,6 +483,15 @@ func TestAddToDirective(t *testing.T) { assert.Contains(t, result, "script-src") assert.Contains(t, result, "https://example.com") }) + + t.Run("does_not_match_a_directive_name_suffix", func(t *testing.T) { + policy := "default-src 'none'; child-frame-src https://legacy.example.com" + result := addToDirective(policy, "frame-src", "'self'") + + assert.Equal(t, 1, countDirectiveValue(result, "frame-src", "'self'")) + assert.Equal(t, 0, countDirectiveValue(result, "child-frame-src", "'self'")) + assert.Equal(t, 1, countDirectiveValue(result, "child-frame-src", "https://legacy.example.com")) + }) } // Benchmark tests diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 60d6516bd0..d618fb45e7 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -20,6 +20,9 @@ func RegisterAdminRoutes( settingService *service.SettingService, panelRateLimiter *middleware.PanelRateLimiter, ) { + // 插件 UI 使用短时能力 URL,仅提供经过安装校验的静态资源。 + v1.GET("/plugin-ui/:token/*path", h.Admin.Plugin.ServeUIAsset) + admin := v1.Group("/admin") admin.Use(gin.HandlerFunc(adminAuth)) // 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免) @@ -100,6 +103,9 @@ func RegisterAdminRoutes( // TLS 指纹模板管理 registerTLSFingerprintProfileRoutes(admin, h) + // 本地进程插件管理 + registerPluginRoutes(admin, h, stepUpAuth) + // API Key 管理 registerAdminAPIKeyRoutes(admin, h) @@ -736,6 +742,22 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand } } +func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { + plugins := admin.Group("/plugins") + { + plugins.GET("", h.Admin.Plugin.List) + plugins.GET("/:id", h.Admin.Plugin.Get) + plugins.POST("/upload", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Upload) + plugins.POST("/:id/enable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Enable) + plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable) + plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete) + plugins.GET("/:id/config", h.Admin.Plugin.GetConfig) + plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig) + plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test) + plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession) + } +} + func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) { channels := admin.Group("/channels") { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index c7958f8a11..4e304eca10 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -43,8 +43,9 @@ import ( var sseDataPrefix = regexp.MustCompile(`^data:\s*`) const ( - testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" - chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" + chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses" + defaultAntigravityTestModel = "claude-sonnet-4-6" ) // TestEvent represents a SSE event for account testing @@ -149,6 +150,7 @@ type AccountTestService struct { modelMetadataRegistryMu sync.Mutex modelMetadataRegistry map[string]modelsDevProvider modelMetadataRegistryAt time.Time + pluginManager *PluginManager agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style @@ -162,6 +164,12 @@ func (s *AccountTestService) SetSettingService(settingService *SettingService) { } } +func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) { + if s != nil { + s.pluginManager = pluginManager + } +} + // NewAccountTestService creates a new AccountTestService func NewAccountTestService( accountRepo AccountRepository, @@ -790,7 +798,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error())) } @@ -2127,7 +2135,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, true) if err != nil { if s.accountRepo != nil { updates := buildOpenAICompactProbeExtraUpdates(nil, nil, err, false, time.Now()) @@ -2307,11 +2315,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() - // 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview - testModelID := modelID - if testModelID == "" { - testModelID = "claude-sonnet-4-5" - } + testModelID := antigravityConnectionTestModel(modelID) if s.antigravityGatewayService == nil { return s.sendErrorAndEnd(c, "Antigravity gateway service not configured") @@ -2342,6 +2346,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac return nil } +func antigravityConnectionTestModel(modelID string) string { + if modelID == "" { + return defaultAntigravityTestModel + } + return modelID +} + // buildGeminiAPIKeyRequest builds request for Gemini API Key accounts func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { apiKey := account.GetCredential("api_key") @@ -3025,7 +3036,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIAccountTestUpstream(req, proxyURL, account, false) if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Responses API request failed: %s", err.Error())) } diff --git a/backend/internal/service/account_test_service_antigravity_test.go b/backend/internal/service/account_test_service_antigravity_test.go new file mode 100644 index 0000000000..b3362f3f69 --- /dev/null +++ b/backend/internal/service/account_test_service_antigravity_test.go @@ -0,0 +1,16 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAntigravityConnectionTestModel(t *testing.T) { + t.Parallel() + + require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel("")) + require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview")) +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 1661eca7c6..f49d3aaa71 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -729,6 +729,9 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou if updates := buildCodexSparkWindowExtraUpdates(quotaUsage, now); len(updates) > 0 { mergeAccountExtra(account, updates) s.persistOpenAICodexProbeSnapshot(account.ID, updates) + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } if usage.UpdatedAt == nil { usage.UpdatedAt = &now } @@ -921,7 +924,9 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u go func() { updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) defer updateCancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index cc09fb808f..ae194f61e1 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -470,6 +470,10 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + accountExtra, err = normalizeOpenAIAutoResetCreditExtra(input.Platform, input.Type, false, accountExtra) + if err != nil { + return nil, err + } // 绑定分组 groupIDs := input.GroupIDs @@ -559,6 +563,14 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + effectiveType := account.Type + if input.Type != "" { + effectiveType = input.Type + } + normalizedExtra, err = normalizeOpenAIAutoResetCreditExtra(account.Platform, effectiveType, account.IsShadow(), normalizedExtra) + if err != nil { + return nil, err + } } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) @@ -649,6 +661,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey, + OpenAIAutoResetCreditStateExtraKey, } { if v, ok := account.Extra[key]; ok { normalizedExtra[key] = v @@ -860,6 +873,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U // (如 model_rate_limits / passive_usage_* 等)。 func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { updates = sanitizedCodexFingerprintExtraUpdates(updates) + updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) delete(updates, UpstreamBillingRateSyncEnabledExtraKey) delete(updates, UpstreamBillingProbeExtraKey) @@ -886,6 +900,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. input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra) + input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true) delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey) delete(input.Extra, UpstreamBillingProbeExtraKey) diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 62ff2e295d..61cc7e31d6 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -69,10 +69,10 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5", + name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-20250929", accountMapping: nil, - expected: "claude-sonnet-4-5", + expected: "claude-sonnet-4-6", }, // 3. 默认映射中的透传(映射到自己) @@ -89,7 +89,7 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-6", }, { - name: "默认映射透传 - claude-sonnet-4-5", + name: "显式 canonical 选择 - claude-sonnet-4-5 透传", requestedModel: "claude-sonnet-4-5", accountMapping: nil, expected: "claude-sonnet-4-5", @@ -113,10 +113,19 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-opus-4-6-thinking", }, { - name: "默认映射透传 - claude-sonnet-4-5-thinking", + name: "默认映射 - claude-sonnet-4-5-thinking → claude-sonnet-4-6", requestedModel: "claude-sonnet-4-5-thinking", accountMapping: nil, - expected: "claude-sonnet-4-5-thinking", + expected: "claude-sonnet-4-6", + }, + { + name: "账户显式目标只映射一步 - custom-sonnet → claude-sonnet-4-5", + requestedModel: "custom-sonnet", + accountMapping: map[string]string{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + expected: "claude-sonnet-4-5", }, { name: "默认映射透传 - gemini-2.5-flash", diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go index 9e09c90443..e296654e6d 100644 --- a/backend/internal/service/antigravity_quota_fetcher.go +++ b/backend/internal/service/antigravity_quota_fetcher.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -28,11 +29,12 @@ const ( // AntigravityQuotaFetcher 从 Antigravity API 获取额度 type AntigravityQuotaFetcher struct { proxyRepo ProxyRepository + cfg *config.Config } // NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher -func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository) *AntigravityQuotaFetcher { - return &AntigravityQuotaFetcher{proxyRepo: proxyRepo} +func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository, cfg *config.Config) *AntigravityQuotaFetcher { + return &AntigravityQuotaFetcher{proxyRepo: proxyRepo, cfg: cfg} } // CanFetch 检查是否可以获取此账户的额度 @@ -55,7 +57,7 @@ func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Accou } // 调用 API 获取配额 - modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) + modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID, resolveModelsListReadLimit(f.cfg)) if err != nil { // 403 Forbidden: 不报错,返回 is_forbidden 标记 var forbiddenErr *antigravity.ForbiddenError diff --git a/backend/internal/service/antigravity_quota_fetcher_test.go b/backend/internal/service/antigravity_quota_fetcher_test.go index e0f5705141..a5463ec007 100644 --- a/backend/internal/service/antigravity_quota_fetcher_test.go +++ b/backend/internal/service/antigravity_quota_fetcher_test.go @@ -3,11 +3,16 @@ package service import ( + "context" "errors" + "net/http" + "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" ) @@ -390,6 +395,36 @@ func TestBuildUsageInfo_AICredits(t *testing.T) { require.Equal(t, 5.0, info.AICredits[0].MinimumBalance) } +func TestFetchQuotaUsesConfiguredModelsListBodyLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":{"model-a":{}}}`)) + })) + defer server.Close() + + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) + oldAvailability := antigravity.DefaultURLAvailability + t.Cleanup(func() { + antigravity.BaseURLs = oldBaseURLs + antigravity.DefaultURLAvailability = oldAvailability + }) + antigravity.BaseURLs = []string{server.URL} + antigravity.DefaultURLAvailability = antigravity.NewURLAvailability(time.Minute) + + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 8 + fetcher := NewAntigravityQuotaFetcher(nil, cfg) + _, err := fetcher.FetchQuota(context.Background(), &Account{ + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + "project_id": "project", + }, + }, "") + require.ErrorContains(t, err, "响应超过 8 字节") +} + func TestFetchQuota_ForbiddenReturnsIsForbidden(t *testing.T) { // 模拟 FetchQuota 遇到 403 时的行为: // FetchAvailableModels 返回 ForbiddenError → FetchQuota 应返回 is_forbidden=true diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index c9b4298291..d632f14bda 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -844,17 +844,28 @@ func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) { require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey) } -func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing.T) { +func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) { repo := &stubAntigravityAccountRepo{} svc := &AntigravityGatewayService{} - account := &Account{ID: 790, Platform: PlatformAntigravity} + account := &Account{ + ID: 790, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "custom-sonnet": "claude-sonnet-4-5", + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } resetAt := time.Now().Add(30 * time.Second) + canonicalModel := resolveFinalAntigravityModelKey(context.Background(), account, "custom-sonnet") + require.Equal(t, "claude-sonnet-4-5", canonicalModel) success := svc.setAntigravityModelRateLimits( context.Background(), repo, account, - "claude-sonnet-4-5", + canonicalModel, "[test]", 429, resetAt, @@ -866,6 +877,33 @@ func TestSetAntigravityModelRateLimits_ClaudeDoesNotWriteGeminiScope(t *testing. require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) } +func TestSetModelRateLimitAndClearSession_UsesUpstreamReportedModelMetadata(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{accountRepo: repo} + account := &Account{ + ID: 791, + Platform: PlatformAntigravity, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-sonnet-4-5": "claude-sonnet-4-6", + }, + }, + } + + svc.setModelRateLimitAndClearSession(&handleModelRateLimitParams{ + ctx: context.Background(), + prefix: "[test]", + account: account, + statusCode: 429, + }, &antigravitySmartRetryInfo{ + RetryDelay: 30 * time.Second, + ModelName: "claude-sonnet-4-5", + }) + + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) +} + func TestAntigravityRetryLoop_PreCheck_SwitchesWhenRateLimited(t *testing.T) { upstream := &recordingOKUpstream{} account := &Account{ diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go new file mode 100644 index 0000000000..05f2a181b1 --- /dev/null +++ b/backend/internal/service/billing_context_schedule.go @@ -0,0 +1,470 @@ +package service + +import ( + "context" + "errors" + "math" + "sort" + "strconv" + "time" +) + +// ContextPricingBasis 阶梯的计价基准。 +type ContextPricingBasis string + +const ( + // ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 + ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" + // ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。 + ContextPricingBasisMarginal ContextPricingBasis = "marginal" +) + +// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 +// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 +type ContextPricingTier struct { + MinTokens int + MaxTokens *int + Label string + Input *float64 + Output *float64 + CacheWrite *float64 + CacheRead *float64 +} + +// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。 +type TimePricingPeriod struct { + StartTime string + EndTime string + Multiplier float64 +} + +// TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 +type TimePricingSchedule struct { + Timezone string + WeekdaysOnly bool + Periods []TimePricingPeriod +} + +// ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。 +// 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。 +// Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。 +type ContextPricingSchedule struct { + Basis ContextPricingBasis + Tiers []ContextPricingTier + TimePricing *TimePricingSchedule +} + +// ContextPricingScheduleInput 阶梯表查询输入。 +type ContextPricingScheduleInput struct { + Model string + // Group 为 nil 表示查官方参考价:无分组、无渠道定价,也不套用平台旧规则。 + Group *Group + // Platform 为请求的具体平台(composite 分组传模型所属平台), + // 决定渠道定价查找与平台旧规则的适用。 + Platform string +} + +var errContextPricingResolverRequired = errors.New("context pricing schedule: resolver is required") + +// 探针步长:相邻两个探针点相差 contextProbeDelta 个 token,单价 = Δcost / Δtoken。 +const contextProbeDelta = 1000 + +// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。 +// +// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价, +// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。 +// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值), +// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等 +// 规则变更无需同步到这里;相邻同价段会合并。 +// +// 非 token 计费模式返回 (nil, nil);模型无任何定价来源时返回 ErrModelPricingUnavailable。 +func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, resolver *ModelPricingResolver, in ContextPricingScheduleInput) (*ContextPricingSchedule, error) { + if s == nil || resolver == nil { + return nil, errContextPricingResolverRequired + } + if ctx == nil { + ctx = context.Background() + } + if in.Platform != "" { + ctx = WithResolvedTargetPlatform(ctx, in.Platform) + } + + pricingInput := PricingInput{Model: in.Model, Group: in.Group} + if in.Group != nil { + gid := in.Group.ID + pricingInput.GroupID = &gid + } + resolved := resolver.Resolve(ctx, pricingInput) + if resolved == nil { + return nil, ErrModelPricingUnavailable + } + if resolved.Mode != "" && resolved.Mode != BillingModeToken { + return nil, nil + } + + var legacy *LegacyLongContextRule + if in.Group != nil { + legacy = s.LegacyLongContextRule(in.Platform) + } + if !legacyLongContextApplies(resolved, in.Group, legacy) { + legacy = nil + } + + req := TokenCostRequest{ + Ctx: ctx, + Model: in.Model, + Group: in.Group, + RateMultiplier: 1, + Resolver: resolver, + Resolved: resolved, + LegacyLongContext: legacy, + } + probe := func(tokens UsageTokens) (*CostBreakdown, error) { + r := req + r.Tokens = tokens + return s.CalculateTokenCostForRequest(r) + } + + plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy) + segments := buildContextSegments(plan.bounds) + + tiers := make([]ContextPricingTier, 0, len(segments)) + for _, seg := range segments { + tier, err := probeContextTier(seg, resolved, probe) + if err != nil { + return nil, err + } + tiers = append(tiers, tier) + } + tiers = mergeEqualContextTiers(tiers) + applyContextTierLabels(tiers, plan) + + basis := ContextPricingBasisWholeRequest + if legacy != nil { + basis = ContextPricingBasisMarginal + } + return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil +} + +// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。 +// 时段来自解析到的渠道定价配置,每个时段的倍率用计费自己的 resolvedChannelTimeMultiplier +// 在时段内取值:定价来源不是渠道(分组价卡覆盖)、配置非法等情况下计费按 1 计, +// 这里也就自然得到"无分时"。倍率为 1 的时段不列出。 +func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule { + if resolved == nil || resolved.channelPricing == nil || resolved.channelPricing.TimePricing == nil { + return nil + } + cfg := resolved.channelPricing.TimePricing + location, err := loadChannelTimePricingLocation(cfg.Timezone) + if err != nil { + return nil + } + type probedPeriod struct { + start int + period TimePricingPeriod + } + probed := make([]probedPeriod, 0, len(cfg.Periods)) + for _, period := range cfg.Periods { + start, err := parseChannelTime(period.StartTime, false) + if err != nil { + continue + } + // 时段按每日循环,取该时段开始后 1 秒作为探针时刻。 + // 锚点日必须是工作日(2026-01-05 为周一):weekdays_only 配置在周末恒为 1, + // 锚点落在周末会把时段整组剔除。 + at := time.Date(2026, time.January, 5, 0, 0, start+1, 0, location) + multiplier := resolvedChannelTimeMultiplier(resolved, at) + if multiplier == 1 { + continue + } + probed = append(probed, probedPeriod{start: start, period: TimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: multiplier, + }}) + } + if len(probed) == 0 { + return nil + } + sort.SliceStable(probed, func(i, j int) bool { return probed[i].start < probed[j].start }) + out := &TimePricingSchedule{ + Timezone: cfg.Timezone, + WeekdaysOnly: cfg.WeekdaysOnly, + Periods: make([]TimePricingPeriod, 0, len(probed)), + } + for _, p := range probed { + out.Periods = append(out.Periods, p.period) + } + return out +} + +// contextBreakpointPlan 描述断点来源。 +type contextBreakpointPlan struct { + bounds []int + // thresholdBound 为目录阶梯/旧规则的断点值((0,b] / (b,∞)),0 表示无。 + thresholdBound int + // thresholdInclusive 为真表示达到阈值即进入高档(断点 = 阈值-1)。 + thresholdInclusive bool + threshold int +} + +// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。 +func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan { + plan := contextBreakpointPlan{} + if legacy != nil { + plan.bounds = []int{legacy.Threshold} + plan.thresholdBound = legacy.Threshold + plan.threshold = legacy.Threshold + return plan + } + if !resolved.longContextPricingEnabled { + return plan + } + if len(resolved.Intervals) > 0 { + // 区间边界即断点;空洞段(含末档上限之外)由计费回落 base,探针会自然得到基础价。 + set := make(map[int]struct{}, len(resolved.Intervals)*2) + for i := range resolved.Intervals { + iv := &resolved.Intervals[i] + if iv.MinTokens > 0 { + set[iv.MinTokens] = struct{}{} + } + if iv.MaxTokens != nil { + set[*iv.MaxTokens] = struct{}{} + } + } + for b := range set { + plan.bounds = append(plan.bounds, b) + } + sort.Ints(plan.bounds) + return plan + } + pricing := resolver.GetIntervalPricing(resolved, 1) + if pricing == nil { + return plan + } + pricing = s.applyModelSpecificPricingPolicy(model, pricing) + if pricing.LongContextInputThreshold <= 0 { + return plan + } + bound := pricing.LongContextInputThreshold + if pricing.LongContextThresholdInclusive { + bound-- + } + if bound <= 0 { + return plan + } + plan.bounds = []int{bound} + plan.thresholdBound = bound + plan.threshold = pricing.LongContextInputThreshold + plan.thresholdInclusive = pricing.LongContextThresholdInclusive + return plan +} + +// contextSegment 探针用的 (min, max] 段;max 为 nil 表示无上限。 +type contextSegment struct { + min int + max *int +} + +// buildContextSegments 把升序断点切成 (0,b1], (b1,b2], …, (bn,∞);无断点时为单个开区间。 +func buildContextSegments(bounds []int) []contextSegment { + segments := make([]contextSegment, 0, len(bounds)+1) + prev := 0 + for _, b := range bounds { + if b <= prev { + continue + } + upper := b + segments = append(segments, contextSegment{min: prev, max: &upper}) + prev = b + } + segments = append(segments, contextSegment{min: prev}) + return segments +} + +// probeContextTier 在段内两点探针,单价 = ΔActualCost / Δtoken(倍率固定为 1)。 +// 每次只喂一种 token,ActualCost 即该项费用;旧边际规则的加倍只体现在 ActualCost +// 而不在分项费用里,因此统一读 ActualCost。整单阶梯与边际规则在同一段内都是 +// 线性函数,差商同时适用,无需区分规则类型。 +func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func(UsageTokens) (*CostBreakdown, error)) (ContextPricingTier, error) { + tier := ContextPricingTier{MinTokens: seg.min, MaxTokens: seg.max} + c := seg.min + 1 + delta := contextProbeDelta + if seg.max != nil { + if width := *seg.max - seg.min; width-1 < delta { + delta = width - 1 + } + } + + var err error + tier.Input, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + tier.CacheRead, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheReadTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + tier.CacheWrite, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } + // 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。 + tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe) + if err != nil { + return tier, err + } + + explicit := explicitContextPricingFields(resolved, c) + tier.Input = contextPricePtr(tier.Input, explicit.input) + tier.Output = contextPricePtr(tier.Output, explicit.output) + tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite) + tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead) + return tier, nil +} + +// probeComponentPrice 返回 [from, from+delta] 上 ActualCost 的差商;delta 为 0(退化段)时退回平均单价。 +func probeComponentPrice(tokensAt func(int) UsageTokens, from, delta int, probe func(UsageTokens) (*CostBreakdown, error)) (*float64, error) { + if delta <= 0 { + n := from + if n <= 0 { + n = 1 + } + cost, err := probe(tokensAt(n)) + if err != nil { + return nil, err + } + v := roundContextPrice(cost.ActualCost / float64(n)) + return &v, nil + } + lo, err := probe(tokensAt(from)) + if err != nil { + return nil, err + } + hi, err := probe(tokensAt(from + delta)) + if err != nil { + return nil, err + } + v := roundContextPrice((hi.ActualCost - lo.ActualCost) / float64(delta)) + return &v, nil +} + +// roundContextPrice 去掉差商带来的浮点噪声(保留 12 位有效数字)。 +func roundContextPrice(v float64) float64 { + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 12, 64), 64) + if err != nil { + return v + } + return r +} + +type explicitContextFields struct { + input, output, cacheWrite, cacheRead bool +} + +// explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。 +// 显式配置为 0 时计费按 $0 收,展示应为 $0 而非“无价”。 +func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) explicitContextFields { + var out explicitContextFields + if resolved == nil || resolved.channelPricing == nil { + return out + } + cp := resolved.channelPricing + out.input = cp.InputPrice != nil + out.output = cp.OutputPrice != nil + out.cacheWrite = cp.CacheWritePrice != nil + out.cacheRead = cp.CacheReadPrice != nil + if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil { + out.input = out.input || iv.InputPrice != nil + out.output = out.output || iv.OutputPrice != nil + out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil + out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil + } + return out +} + +func contextPricePtr(v *float64, explicit bool) *float64 { + if v == nil { + return nil + } + if *v == 0 && !explicit { + return nil + } + return v +} + +// mergeEqualContextTiers 合并相邻且四项单价相同的段(倍率 ≤1 的目录、关闭阶梯等场景塌成单档)。 +func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier { + if len(tiers) < 2 { + return tiers + } + merged := make([]ContextPricingTier, 0, len(tiers)) + for _, t := range tiers { + if n := len(merged); n > 0 && sameContextPrices(merged[n-1], t) { + merged[n-1].MaxTokens = t.MaxTokens + continue + } + merged = append(merged, t) + } + return merged +} + +func sameContextPrices(a, b ContextPricingTier) bool { + return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) && + samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheRead, b.CacheRead) +} + +func samePricePtr(a, b *float64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if *a == *b { + return true + } + scale := math.Max(math.Abs(*a), math.Abs(*b)) + return math.Abs(*a-*b) <= scale*1e-9 +} + +// applyContextTierLabels 给多档阶梯打统一形态的标签:有上限的档为「≤上限」, +// 末档为「>下限」;档位按上下文升序,因此相邻的 ≤100K / ≤200K 即表示 (100K,200K]。 +// 目录阶梯/旧规则在"达到阈值即进高档"时改用 < / ≥ 表达阈值本身。 +// 渠道区间上的 tier_label 不用于 token 档位(token 模式的管理表单不暴露该字段)。 +func applyContextTierLabels(tiers []ContextPricingTier, plan contextBreakpointPlan) { + if len(tiers) < 2 { + return + } + for i := range tiers { + t := &tiers[i] + switch { + case plan.thresholdInclusive && t.MaxTokens != nil && *t.MaxTokens == plan.thresholdBound: + t.Label = "<" + formatContextTokenCount(plan.threshold) + case plan.thresholdInclusive && t.MinTokens == plan.thresholdBound: + t.Label = "≥" + formatContextTokenCount(plan.threshold) + case t.MaxTokens != nil: + t.Label = "≤" + formatContextTokenCount(*t.MaxTokens) + default: + t.Label = ">" + formatContextTokenCount(t.MinTokens) + } + } +} + +// formatContextTokenCount 把 token 数格式化为 272K / 1M 等短标签。 +func formatContextTokenCount(n int) string { + switch { + case n >= 1_000_000 && n%1_000_000 == 0: + return strconv.Itoa(n/1_000_000) + "M" + case n >= 1_000_000: + return trimFloatLabel(float64(n)/1_000_000) + "M" + case n >= 1_000: + return trimFloatLabel(float64(n)/1_000) + "K" + } + return strconv.Itoa(n) +} + +func trimFloatLabel(v float64) string { + return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64) +} diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go new file mode 100644 index 0000000000..b13606da9f --- /dev/null +++ b/backend/internal/service/billing_context_schedule_test.go @@ -0,0 +1,620 @@ +//go:build unit + +package service + +import ( + "context" + "math" + "math/rand" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// scheduleScenario 阶梯表测试场景:同一份场景既做断言也做与计费函数的对账。 +type scheduleScenario struct { + name string + model string + platform string + groupPlatform string + group *Group + channel []ChannelModelPricing + catalog *PricingService + wantErr bool + wantNil bool + wantBasis ContextPricingBasis + check func(t *testing.T, s *ContextPricingSchedule) +} + +func enabledGroup(platform string) *Group { + return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: true} +} + +func disabledGroup(platform string) *Group { + return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: false} +} + +func sonnetChannel(iv ...PricingInterval) []ChannelModelPricing { + return []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(2e-6), Intervals: iv, + }} +} + +func requireTier(t *testing.T, tier ContextPricingTier, min int, max *int, label string, input, output, cacheWrite, cacheRead *float64) { + t.Helper() + require.Equal(t, min, tier.MinTokens, "min") + if max == nil { + require.Nil(t, tier.MaxTokens, "max should be unbounded") + } else { + require.NotNil(t, tier.MaxTokens, "max") + require.Equal(t, *max, *tier.MaxTokens, "max") + } + require.Equal(t, label, tier.Label, "label") + requirePrice(t, input, tier.Input, "input") + requirePrice(t, output, tier.Output, "output") + requirePrice(t, cacheWrite, tier.CacheWrite, "cache_write") + requirePrice(t, cacheRead, tier.CacheRead, "cache_read") +} + +func requirePrice(t *testing.T, want, got *float64, field string) { + t.Helper() + if want == nil { + require.Nil(t, got, field) + return + } + require.NotNil(t, got, field) + require.InDelta(t, *want, *got, 1e-15, field) +} + +func scheduleScenarios() []scheduleScenario { + p := testPtrFloat64 + return []scheduleScenario{ + { + name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) + }, + }, + { + name: "Grok 达到阈值即进高档", model: "grok-4.5", platform: PlatformGrok, groupPlatform: PlatformGrok, + group: enabledGroup(PlatformGrok), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(199999), "<200K", p(2e-6), p(6e-6), nil, p(0.3e-6)) + requireTier(t, s.Tiers[1], 199999, nil, "≥200K", p(4e-6), p(12e-6), nil, p(0.6e-6)) + }, + }, + { + name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) + }, + }, + { + name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI, + group: nil, wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) + }, + }, + { + name: "渠道倍率区间按渠道平价覆盖后的 base 折算(区间自定义标签不用于档位)", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, TierLabel: "long", InputMultiplier: p(2), OutputMultiplier: p(1.5), CacheWriteMultiplier: p(2), CacheReadMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6)) + }, + }, + { + name: "区间显式价优先于倍率", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, InputPrice: p(9e-6), InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(9e-6), s.Tiers[1].Input, "input") + }, + }, + { + name: "区间空洞按 base 补档且同价段合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(0.5)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 3) + requireTier(t, s.Tiers[0], 0, intPtr(100000), "≤100K", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 100000, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[2], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "首档同价空洞被合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "末档有上限时尾段回落 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)}, + PricingInterval{MinTokens: 200000, MaxTokens: intPtr(1000000), InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 3) + requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "≤1M", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + requireTier(t, s.Tiers[2], 1000000, nil, ">1M", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组关闭时渠道区间折叠到 1-token 档", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel( + PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(0.5)}, + PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}, + ), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组关闭且首档不从 0 起时平价为 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest, + channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}), + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requirePrice(t, p(2e-6), s.Tiers[0].Input, "input") + }, + }, + { + name: "分组 token 价卡整张替换渠道定价并剥区间", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + Intervals: []PricingInterval{{MinTokens: 200000, InputMultiplier: p(5)}}, + }}}, + channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(3)}), + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6)) + }, + }, + { + name: "分组价卡之上叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + }}}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(1e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(2e-6), s.Tiers[1].Input, "input") + requirePrice(t, p(22.5e-6), s.Tiers[1].Output, "output") + }, + }, + { + name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6)) + }, + }, + { + name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + }, + }, + { + name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), + channel: []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + requirePrice(t, p(3e-6), s.Tiers[0].Input, "input") + }, + }, + { + name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, + group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + }, + }, + { + name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite, + group: enabledGroup(PlatformComposite), + channel: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requirePrice(t, p(1e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(2e-6), s.Tiers[1].Input, "input") + }, + }, + { + name: "渠道显式 cache_write=0 保留为 0", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic, + group: enabledGroup(PlatformAnthropic), + channel: []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: p(2e-6), CacheWritePrice: p(0), + }}, + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 1) + require.NotNil(t, s.Tiers[0].CacheWrite) + require.Zero(t, *s.Tiers[0].CacheWrite) + }, + }, + { + name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), + catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6}, + }), + wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(5e-6), p(30e-6), p(6.25e-6), p(0.5e-6)) + requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(10e-6), p(45e-6), p(12.5e-6), p(1e-6)) + }, + }, + { + name: "图片模式返回 nil", model: "gpt-image-2", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), + channel: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, PerRequestPrice: p(0.04), + }}, + wantNil: true, + }, + { + name: "无任何定价来源报错", model: "unknown-model-xyz", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, + group: enabledGroup(PlatformOpenAI), wantErr: true, + }, + } +} + +func newScheduleTestEnv(t *testing.T, sc scheduleScenario) (*BillingService, *ModelPricingResolver) { + t.Helper() + return newTokenCostTestEnv(t, sc.groupPlatform, sc.channel, sc.catalog) +} + +func TestResolveContextPricingSchedule_Scenarios(t *testing.T) { + for _, sc := range scheduleScenarios() { + t.Run(sc.name, func(t *testing.T) { + bs, resolver := newScheduleTestEnv(t, sc) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: sc.model, Group: sc.group, Platform: sc.platform, + }) + if sc.wantErr { + require.Error(t, err) + require.Nil(t, sched) + return + } + require.NoError(t, err) + if sc.wantNil { + require.Nil(t, sched) + return + } + require.NotNil(t, sched) + require.Equal(t, sc.wantBasis, sched.Basis) + require.NotEmpty(t, sched.Tiers) + require.Zero(t, sched.Tiers[0].MinTokens, "首档从 0 起") + for i := 1; i < len(sched.Tiers); i++ { + require.NotNil(t, sched.Tiers[i-1].MaxTokens) + require.Equal(t, *sched.Tiers[i-1].MaxTokens, sched.Tiers[i].MinTokens, "档位连续") + require.Less(t, sched.Tiers[i-1].MinTokens, sched.Tiers[i].MinTokens, "档位按上下文升序") + } + if len(sched.Tiers) > 1 { + for i, tier := range sched.Tiers { + require.NotEmpty(t, tier.Label, "多档时每档都有标签 #%d", i) + } + require.Nil(t, sched.Tiers[len(sched.Tiers)-1].MaxTokens, "末档无上限") + } + if sc.check != nil { + sc.check(t, sched) + } + }) + } +} + +func TestResolveContextPricingSchedule_NilResolver(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), nil, ContextPricingScheduleInput{Model: "gpt-5.4"}) + require.Error(t, err) + require.Nil(t, sched) +} + +// --- 对账:阶梯表推算的费用必须等于真实计费函数 --- + +type tokenKind int + +const ( + kindInput tokenKind = iota + kindOutput + kindCacheRead + kindCacheWrite +) + +func tierPrice(tier ContextPricingTier, kind tokenKind) float64 { + var p *float64 + switch kind { + case kindInput: + p = tier.Input + case kindOutput: + p = tier.Output + case kindCacheRead: + p = tier.CacheRead + case kindCacheWrite: + p = tier.CacheWrite + } + if p == nil { + return 0 + } + return *p +} + +func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier { + for _, tier := range tiers { + if contextTokens > tier.MinTokens && (tier.MaxTokens == nil || contextTokens <= *tier.MaxTokens) { + return tier + } + } + return tiers[len(tiers)-1] +} + +// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用: +// 整单基准取所在档单价 × 全量;边际基准逐段累加。 +func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 { + if s.Basis == ContextPricingBasisMarginal { + total := 0.0 + for _, tier := range s.Tiers { + if contextTokens <= tier.MinTokens { + break + } + upper := contextTokens + if tier.MaxTokens != nil && *tier.MaxTokens < upper { + upper = *tier.MaxTokens + } + total += float64(upper-tier.MinTokens) * tierPrice(tier, kind) + } + return total + } + return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind) +} + +func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) { + const outputProbe = 1000 + rng := rand.New(rand.NewSource(20260823)) + for _, sc := range scheduleScenarios() { + if sc.wantErr || sc.wantNil { + continue + } + t.Run(sc.name, func(t *testing.T) { + bs, resolver := newScheduleTestEnv(t, sc) + ctx := context.Background() + if sc.platform != "" { + ctx = WithResolvedTargetPlatform(ctx, sc.platform) + } + sched, err := bs.ResolveContextPricingSchedule(ctx, resolver, ContextPricingScheduleInput{ + Model: sc.model, Group: sc.group, Platform: sc.platform, + }) + require.NoError(t, err) + require.NotNil(t, sched) + + // 消费方视角重建同一份计费请求(与网关一致)。 + pricingInput := PricingInput{Model: sc.model, Group: sc.group} + if sc.group != nil { + gid := sc.group.ID + pricingInput.GroupID = &gid + } + resolved := resolver.Resolve(ctx, pricingInput) + var legacy *LegacyLongContextRule + if sc.group != nil { + legacy = bs.LegacyLongContextRule(sc.platform) + } + if !legacyLongContextApplies(resolved, sc.group, legacy) { + legacy = nil + } + cost := func(tokens UsageTokens) float64 { + bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy, + }) + require.NoError(t, err) + return bd.ActualCost + } + + probes := []int{1, 2, 999, 1000, 1001, 10_000_000} + for _, tier := range sched.Tiers { + for _, b := range []*int{tier.MaxTokens} { + if b == nil { + continue + } + probes = append(probes, *b-1, *b, *b+1) + } + } + for i := 0; i < 8; i++ { + probes = append(probes, 1+rng.Intn(3_000_000)) + } + + for _, c := range probes { + if c <= 0 { + continue + } + assertCostClose(t, expectedCostFromSchedule(sched, kindInput, c), cost(UsageTokens{InputTokens: c}), "input@%d", c) + assertCostClose(t, expectedCostFromSchedule(sched, kindCacheRead, c), cost(UsageTokens{CacheReadTokens: c}), "cache_read@%d", c) + assertCostClose(t, expectedCostFromSchedule(sched, kindCacheWrite, c), cost(UsageTokens{CacheCreationTokens: c}), "cache_write@%d", c) + wantOutput := float64(outputProbe) * tierPrice(tierAt(sched.Tiers, c), kindOutput) + gotOutput := cost(UsageTokens{InputTokens: c, OutputTokens: outputProbe}) - cost(UsageTokens{InputTokens: c}) + assertCostClose(t, wantOutput, gotOutput, "output@%d", c) + } + }) + } +} + +func assertCostClose(t *testing.T, want, got float64, format string, args ...any) { + t.Helper() + tolerance := 1e-12 + 1e-9*math.Abs(want) + require.InDeltaf(t, want, got, tolerance, format, args...) +} + +func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing { + return []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(2e-6), TimePricing: tp, + }} +} + +func TestResolveContextPricingSchedule_TimePricing(t *testing.T) { + valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "12:00", EndTime: "13:00", Multiplier: 1}, + }} + + t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.NotNil(t, sched.TimePricing) + require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone) + require.False(t, sched.TimePricing.WeekdaysOnly) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + }, sched.TimePricing.Periods) + // 阶梯表单价是标准时段价,不含分时倍率 + requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input") + + // 对账:时段内的真实计费 = 标准单价 × token × 倍率 + group := enabledGroup(PlatformAnthropic) + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group}) + loc, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, tc := range []struct { + at time.Time + want float64 + }{ + {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5}, + {time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1}, + {time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2}, + {time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1}, + } { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at) + } + }) + + t.Run("仅工作日配置透传标注且时段仍列出", func(t *testing.T) { + weekdays := &ChannelTimePricing{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(weekdays), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.NotNil(t, sched.TimePricing, "探针锚点必须落在工作日,否则仅工作日配置的时段会被整组剔除") + require.True(t, sched.TimePricing.WeekdaysOnly) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }, sched.TimePricing.Periods) + + // 对账:工作日时段内乘倍率,周末同一时段按标准价 + group := enabledGroup(PlatformAnthropic) + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group}) + loc, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, tc := range []struct { + at time.Time + want float64 + }{ + {time.Date(2026, 8, 24, 3, 0, 0, 0, loc), 0.5}, // 周一 + {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 1}, // 周日 + } { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at) + } + }) + + t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) { + invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠 + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6), + }}} + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("无分时配置为 nil", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 6345f2c40e..a2727af7a4 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "math" "strings" "sync" "time" @@ -1370,16 +1371,43 @@ func (s *BillingService) computeTokenBreakdown( // multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。 func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 { if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) { - if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 { + cacheCreation5mTokens, cacheCreation1hTokens := normalizeCacheCreationBreakdown(tokens) + if cacheCreation5mTokens == 0 && cacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 { // API 未返回 ephemeral 明细,回退到全部按 5m 单价计费 return float64(tokens.CacheCreationTokens) * pricing.CacheCreation5mPrice * multiplier } - return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier + - float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier + return float64(cacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier + + float64(cacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier } return float64(tokens.CacheCreationTokens) * price * multiplier } +// normalizeCacheCreationBreakdown caps contradictory 5m/1h details at an explicitly +// positive aggregate while retaining their reported ratio as closely as integer tokens allow. +func normalizeCacheCreationBreakdown(tokens UsageTokens) (int, int) { + cacheCreation5mTokens := tokens.CacheCreation5mTokens + cacheCreation1hTokens := tokens.CacheCreation1hTokens + aggregate := tokens.CacheCreationTokens + if cacheCreation5mTokens < 0 { + cacheCreation5mTokens = 0 + } + if cacheCreation1hTokens < 0 { + cacheCreation1hTokens = 0 + } + if aggregate <= 0 || (cacheCreation5mTokens <= aggregate && cacheCreation1hTokens <= aggregate-cacheCreation5mTokens) { + return cacheCreation5mTokens, cacheCreation1hTokens + } + + detailTotal := float64(cacheCreation5mTokens) + float64(cacheCreation1hTokens) + normalized5mTokens := math.Round(float64(aggregate) * float64(cacheCreation5mTokens) / detailTotal) + if normalized5mTokens >= float64(aggregate) { + cacheCreation5mTokens = aggregate + } else { + cacheCreation5mTokens = int(normalized5mTokens) + } + return cacheCreation5mTokens, aggregate - cacheCreation5mTokens +} + // calculatePerRequestCost 按次/图片计费 func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { units := input.UsageUnits @@ -1475,7 +1503,8 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) - if !needsLongContextPolicy && !needsCacheCreationPolicy { + fastRatio := openAIModelFastPricingRatio(normalized) + if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing @@ -1498,9 +1527,45 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier } } + if fastRatio > 0 { + enforceOpenAIFastPricingRatio(&cloned, fastRatio) + } return &cloned } +// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority +// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast +// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 +func openAIModelFastPricingRatio(normalized string) float64 { + switch normalized { + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna": + return 2.0 + case "gpt-5.5": + return 2.5 + default: + return 0 + } +} + +// enforceOpenAIFastPricingRatio 把 priority 档价格改写为「标准价 × ratio」。 +// 本地/远程 LiteLLM 目录可能只带官方旧口径(如 gpt-5.5 priority 仍标 2x), +// 直接采用会导致 Fast 模式少计费;这里按业务倍率兜底修正,且对已正确的 +// fallback 条目(2x/2.5x)是幂等的。computeTokenBreakdown 在 priority 价格 +// 存在时走显式档位价、不再叠加通用 tier 倍率,因此不会重复乘价。 +func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) { + if pricing == nil || ratio <= 0 { + return + } + pricing.InputPricePerTokenPriority = pricing.InputPricePerToken * ratio + pricing.OutputPricePerTokenPriority = pricing.OutputPricePerToken * ratio + if pricing.CacheReadPricePerToken > 0 { + pricing.CacheReadPricePerTokenPriority = pricing.CacheReadPricePerToken * ratio + } + if pricing.CacheCreationPricePerToken > 0 { + pricing.CacheCreationPricePerTokenPriority = pricing.CacheCreationPricePerToken * ratio + } +} + func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool { if pricing == nil || pricing.LongContextInputThreshold <= 0 { return false diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 7248878a2e..0f8bbacf9a 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1384,6 +1384,122 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) { require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) } +func TestComputeCacheCreationCost_CapsContradictoryBreakdownAtAggregate(t *testing.T) { + svc := &BillingService{} + pricing := &ModelPricing{ + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 1, + CacheCreation1hPrice: 1, + } + + tokens := UsageTokens{ + CacheCreationTokens: 463184, + CacheCreation5mTokens: 463184, + CacheCreation1hTokens: 463184, + } + + cost := svc.computeCacheCreationCost(pricing, tokens, 0, 1) + require.Equal(t, float64(tokens.CacheCreationTokens), cost, + "billed cache-creation token equivalent must not exceed the positive aggregate") +} + +func TestNormalizeCacheCreationBreakdown_BillingSafetyInvariant(t *testing.T) { + tests := []struct { + name string + tokens UsageTokens + want5m int + want1h int + }{ + { + name: "preserves ratio when capping", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 90, CacheCreation1hTokens: 60}, + want5m: 60, + want1h: 40, + }, + { + name: "details below aggregate unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 60}, + want5m: 30, + want1h: 60, + }, + { + name: "absent 5m detail unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation1hTokens: 60}, + want5m: 0, + want1h: 60, + }, + { + name: "absent 1h detail unchanged", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30}, + want5m: 30, + want1h: 0, + }, + { + name: "negative detail clamped", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 60}, + want5m: 0, + want1h: 60, + }, + { + name: "negative detail cannot hide oversized positive detail", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 150}, + want5m: 0, + want1h: 100, + }, + { + name: "integer boundary details capped without overflow", + tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: int(^uint(0) >> 1)}, + want5m: 50, + want1h: 50, + }, + { + name: "integer boundary aggregate avoids float conversion overflow", + tokens: UsageTokens{CacheCreationTokens: int(^uint(0) >> 1), CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: 1}, + want5m: int(^uint(0) >> 1), + want1h: 0, + }, + { + name: "zero aggregate unchanged", + tokens: UsageTokens{CacheCreation5mTokens: 90, CacheCreation1hTokens: 60}, + want5m: 90, + want1h: 60, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got5m, got1h := normalizeCacheCreationBreakdown(tt.tokens) + require.Equal(t, tt.want5m, got5m) + require.Equal(t, tt.want1h, got1h) + }) + } +} + +func TestComputeCacheCreationCost_PreservesZeroDetailFallback(t *testing.T) { + svc := &BillingService{} + pricing := &ModelPricing{ + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 4e-6, + CacheCreation1hPrice: 5e-6, + } + + tests := []struct { + name string + tokens UsageTokens + }{ + {name: "zero details", tokens: UsageTokens{CacheCreationTokens: 100}}, + {name: "one negative detail", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25}}, + {name: "both negative details", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25, CacheCreation1hTokens: -75}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cost := svc.computeCacheCreationCost(pricing, tt.tokens, 0, 1) + require.InDelta(t, 100*4e-6, cost, 1e-12) + }) + } +} + func TestCalculateCost_LargeTokenCount(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go new file mode 100644 index 0000000000..efd8aa6606 --- /dev/null +++ b/backend/internal/service/billing_token_cost_request.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "time" +) + +// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。 +// +// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier, +// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。 +// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用; +// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。 +type LegacyLongContextRule struct { + Threshold int + Multiplier float64 +} + +const ( + geminiLegacyLongContextThreshold = 200000 + geminiLegacyLongContextMultiplier = 2.0 +) + +// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。 +func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule { + if platform == PlatformGemini { + return &LegacyLongContextRule{ + Threshold: geminiLegacyLongContextThreshold, + Multiplier: geminiLegacyLongContextMultiplier, + } + } + return nil +} + +// TokenCostRequest 通用网关 token 计费请求。 +type TokenCostRequest struct { + Ctx context.Context + Model string + Group *Group + Tokens UsageTokens + RateMultiplier float64 + PricingAt time.Time + ServiceTier string + Resolver *ModelPricingResolver + // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 + Resolved *ResolvedPricing + // LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。 + LegacyLongContext *LegacyLongContextRule +} + +// legacyLongContextApplies 判定请求是否走旧长上下文规则: +// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。 +func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool { + if rule == nil || rule.Threshold <= 0 { + return false + } + if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { + return false + } + return group == nil || group.LongContextPricingEnabled +} + +// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用: +// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中); +// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费; +// 3. 否则有解析器与分组 → 统一计费(内置目录定价); +// 4. 否则按模型目录直接计费。 +// +// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。 +func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) { + resolved := req.Resolved + if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } + if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) { + return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier, + req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier) + } + if req.Resolver != nil && req.Group != nil { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } + return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier) +} + +func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput { + input := CostInput{ + Ctx: req.Ctx, + Model: req.Model, + Group: req.Group, + Tokens: req.Tokens, + RequestCount: 1, + RateMultiplier: req.RateMultiplier, + PricingAt: req.PricingAt, + ServiceTier: req.ServiceTier, + Resolver: req.Resolver, + Resolved: resolved, + } + if req.Group != nil { + gid := req.Group.ID + input.GroupID = &gid + } + return input +} diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go new file mode 100644 index 0000000000..8392b8cf49 --- /dev/null +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -0,0 +1,147 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// newTokenCostTestEnv 构造带渠道定价的计费环境:group 100 挂一个渠道,定价由 pricing 指定。 +func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelModelPricing, catalog *PricingService) (*BillingService, *ModelPricingResolver) { + t.Helper() + repo := &mockChannelRepository{ + listAllFn: func(_ context.Context) ([]Channel, error) { + return []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{100}, ModelPricing: pricing, + }}, nil + }, + getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) { + return map[int64]string{100: groupPlatform}, nil + }, + } + cs := NewChannelService(repo, nil, nil, nil) + bs := NewBillingService(&config.Config{}, catalog) + return bs, NewModelPricingResolver(cs, bs) +} + +func geminiCatalogStub() *PricingService { + return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gemini-2.5-pro": { + Mode: "chat", + InputCostPerToken: 1.25e-6, + OutputCostPerToken: 10e-6, + CacheReadInputTokenCost: 0.3125e-6, + }, + }) +} + +func TestLegacyLongContextRule_OnlyGemini(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + rule := bs.LegacyLongContextRule(PlatformGemini) + require.NotNil(t, rule) + require.Equal(t, 200000, rule.Threshold) + require.InDelta(t, 2.0, rule.Multiplier, 1e-12) + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} { + require.Nil(t, bs.LegacyLongContextRule(platform), platform) + } +} + +func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6), + }}, geminiCatalogStub()) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceChannel, resolved.Source) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini), + }) + require.NoError(t, err) + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.Equal(t, want, got) + // 渠道平价 10e-6 × 300K,旧规则未叠加 + require.InDelta(t, 3.0, got.InputCost, 1e-9) +} + +func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + rule := bs.LegacyLongContextRule(PlatformGemini) + + for _, enabled := range []bool{true, false} { + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, LegacyLongContext: rule, + }) + require.NoError(t, err) + if enabled { + want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier) + require.NoError(t, err) + require.Equal(t, want, got) + // 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。 + // 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。 + require.InDelta(t, 0.51, got.ActualCost, 1e-9) + require.True(t, got.LongContextBillingApplied) + } else { + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.Equal(t, want, got) + require.InDelta(t, 0.385, got.ActualCost, 1e-9) + require.False(t, got.LongContextBillingApplied) + } + } +} + +func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil) + group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true} + gid := group.ID + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gpt-5.4", GroupID: &gid, Group: group}) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gpt-5.4", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + want, err := bs.CalculateCostUnified(CostInput{ + Ctx: context.Background(), Model: "gpt-5.4", GroupID: &gid, Group: group, Tokens: tokens, + RequestCount: 1, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + require.Equal(t, want, got) + // 目录阶梯:超 272K 整单输入 ×2 + require.InDelta(t, 300000*2.5e-6*2, got.InputCost, 1e-9) + require.True(t, got.LongContextBillingApplied) +} + +func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{Model: "gpt-5.4", Tokens: tokens, RateMultiplier: 1}) + require.NoError(t, err) + want, err := bs.CalculateCost("gpt-5.4", tokens, 1) + require.NoError(t, err) + require.Equal(t, want, got) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 0fe4e3b07c..3e300edf99 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -109,8 +109,9 @@ type ChannelModelPricing struct { // ChannelTimePricing 渠道模型定价的分时倍率配置。 type ChannelTimePricing struct { - Timezone string `json:"timezone"` - Periods []ChannelTimePricingPeriod `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []ChannelTimePricingPeriod `json:"periods"` } // ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。 @@ -216,7 +217,10 @@ func (p ChannelModelPricing) Clone() ChannelModelPricing { copy(cp.Intervals, p.Intervals) } if p.TimePricing != nil { - cp.TimePricing = &ChannelTimePricing{Timezone: p.TimePricing.Timezone} + cp.TimePricing = &ChannelTimePricing{ + Timezone: p.TimePricing.Timezone, + WeekdaysOnly: p.TimePricing.WeekdaysOnly, + } if p.TimePricing.Periods != nil { cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...) } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index eeaf7dac2e..6face6e1db 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -90,7 +90,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, ch.normalizeBillingModelSource() supported := ch.SupportedModels() - s.fillGlobalPricingFallback(supported) + fillGlobalPricingFallback(s.pricingService, supported) out = append(out, AvailableChannel{ ID: ch.ID, @@ -117,16 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 s.pricingService 为 nil(测试场景),跳过回落。 -func (s *ChannelService) fillGlobalPricingFallback(models []SupportedModel) { - if s.pricingService == nil { +// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。 +func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { + if pricingService == nil { return } for i := range models { if !pricingNeedsFallback(models[i].Pricing) { continue } - lp := s.pricingService.GetModelPricing(models[i].Name) + lp := pricingService.GetModelPricing(models[i].Name) if lp == nil { continue } diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index d59e587ecd..2b7b23f7e5 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -255,7 +255,7 @@ func TestFillGlobalPricingFallback_NilPricing(t *testing.T) { models := []SupportedModel{ {Name: "claude-opus-4-5", Platform: "anthropic"}, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.NotNil(t, models[0].Pricing) require.NotNil(t, models[0].Pricing.InputPrice) require.InDelta(t, 5e-6, *models[0].Pricing.InputPrice, 1e-12) @@ -281,7 +281,7 @@ func TestFillGlobalPricingFallback_EmptyPricingFillsFromLiteLLM(t *testing.T) { }, }, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.NotNil(t, models[0].Pricing) require.Equal(t, BillingModeImage, models[0].Pricing.BillingMode) require.NotNil(t, models[0].Pricing.ImageOutputPrice) @@ -302,7 +302,7 @@ func TestFillGlobalPricingFallback_KeepsExistingPrice(t *testing.T) { models := []SupportedModel{ {Name: "served-model", Platform: "anthropic", Pricing: existing}, } - svc.fillGlobalPricingFallback(models) + fillGlobalPricingFallback(svc.pricingService, models) require.Same(t, existing, models[0].Pricing) } diff --git a/backend/internal/service/channel_plaza.go b/backend/internal/service/channel_plaza.go deleted file mode 100644 index 1e4dee2717..0000000000 --- a/backend/internal/service/channel_plaza.go +++ /dev/null @@ -1,256 +0,0 @@ -package service - -import ( - "context" - "fmt" - "sort" - "strings" -) - -// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。 -// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。 -type PlazaOfficialPricing struct { - InputPrice *float64 - OutputPrice *float64 - CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation) - CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr) - CacheReadPrice *float64 -} - -// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。 -type PlazaModel struct { - Name string - Platform string - Pricing *ChannelModelPricing - OfficialPricing *PlazaOfficialPricing -} - -// PlazaGroup 模型广场中以分组为顶层的条目。 -// -// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 -// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 -// 具体平台),与「可用渠道」页口径一致。 -type PlazaGroup struct { - ID int64 - Name string - Description string - Platform string - SubscriptionType string - RateMultiplier float64 - PeakRateEnabled bool - PeakStart string - PeakEnd string - PeakRateMultiplier float64 - IsExclusive bool - // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付 - // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 - ImageRateIndependent bool - ImageRateMultiplier float64 - Models []PlazaModel -} - -// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。 -// -// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、 -// 平台隔离),仅把顶层从渠道换成分组: -// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定; -// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换; -// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价, -// 见 plazaImageDisplayPricing); -// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil); -// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称), -// 组内模型按名称排序。 -// -// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。 -func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) { - channels, err := s.repo.ListAll(ctx) - if err != nil { - return nil, fmt.Errorf("list channels: %w", err) - } - groups, err := s.groupRepo.ListActive(ctx) - if err != nil { - return nil, fmt.Errorf("list active groups: %w", err) - } - - sort.SliceStable(channels, func(i, j int) bool { - return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name) - }) - - byGroup := make(map[int64]*PlazaGroup, len(groups)) - groupEnt := make(map[int64]*Group, len(groups)) - order := make([]int64, 0, len(groups)) - for i := range groups { - g := &groups[i] - byGroup[g.ID] = &PlazaGroup{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - Platform: g.Platform, - SubscriptionType: g.SubscriptionType, - RateMultiplier: g.RateMultiplier, - PeakRateEnabled: g.PeakRateEnabled, - PeakStart: g.PeakStart, - PeakEnd: g.PeakEnd, - PeakRateMultiplier: g.PeakRateMultiplier, - IsExclusive: g.IsExclusive, - ImageRateIndependent: g.ImageRateIndependent, - ImageRateMultiplier: g.ImageRateMultiplier, - } - groupEnt[g.ID] = g - order = append(order, g.ID) - } - - type modelKey struct { - platform string - name string - } - // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models - modelIdx := make(map[int64]map[modelKey]int, len(groups)) - for i := range channels { - ch := &channels[i] - if ch.Status != StatusActive { - continue - } - ch.normalizeBillingModelSource() - supported := ch.SupportedModels() - s.fillGlobalPricingFallback(supported) - - for _, gid := range ch.GroupIDs { - pg, ok := byGroup[gid] - if !ok { - continue - } - idx := modelIdx[gid] - if idx == nil { - idx = make(map[modelKey]int, len(supported)) - modelIdx[gid] = idx - } - for j := range supported { - m := supported[j] - if pg.Platform == PlatformComposite { - if !isConcreteRequestPlatform(m.Platform) { - continue - } - } else if m.Platform != pg.Platform { - continue - } - pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid]) - key := modelKey{platform: m.Platform, name: m.Name} - if at, seen := idx[key]; seen { - // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 - if pg.Models[at].Pricing == nil && pricing != nil { - pg.Models[at].Pricing = pricing - } - continue - } - idx[key] = len(pg.Models) - pg.Models = append(pg.Models, PlazaModel{ - Name: m.Name, - Platform: m.Platform, - Pricing: pricing, - }) - } - } - } - - officialMemo := make(map[string]*PlazaOfficialPricing) - out := make([]PlazaGroup, 0, len(order)) - for _, gid := range order { - pg := byGroup[gid] - if len(pg.Models) == 0 { - continue - } - sort.SliceStable(pg.Models, func(i, j int) bool { - if pg.Models[i].Name != pg.Models[j].Name { - return pg.Models[i].Name < pg.Models[j].Name - } - return pg.Models[i].Platform < pg.Models[j].Platform - }) - for j := range pg.Models { - pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo) - } - out = append(out, *pg) - } - - sort.SliceStable(out, func(i, j int) bool { - if out[i].RateMultiplier != out[j].RateMultiplier { - return out[i].RateMultiplier < out[j].RateMultiplier - } - return out[i].Name < out[j].Name - }) - return out, nil -} - -// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致: -// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。 -// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参 -// (渠道定价指针指向缓存共享数据)。 -func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing { - if p == nil || g == nil || p.BillingMode != BillingModeImage { - return p - } - if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil { - return p - } - channelTierPrice := func(label string) *float64 { - for i := range p.Intervals { - if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil { - return p.Intervals[i].PerRequestPrice - } - } - return p.PerRequestPrice - } - tiers := []struct { - label string - groupPrice *float64 - }{ - {"1K", g.ImagePrice1K}, - {"2K", g.ImagePrice2K}, - {"4K", g.ImagePrice4K}, - } - clone := *p - clone.Intervals = make([]PricingInterval, 0, len(tiers)) - for i, t := range tiers { - price := t.groupPrice - if price == nil { - price = channelTierPrice(t.label) - } - if price == nil { - continue - } - v := *price - clone.Intervals = append(clone.Intervals, PricingInterval{ - TierLabel: t.label, - PerRequestPrice: &v, - SortOrder: i, - }) - } - return &clone -} - -// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。 -// pricingService 为 nil(测试场景)或查不到时返回 nil。 -func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing { - if s.pricingService == nil { - return nil - } - if cached, ok := memo[modelName]; ok { - return cached - } - var result *PlazaOfficialPricing - if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent { - result = &PlazaOfficialPricing{ - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - } - if result.InputPrice == nil && result.OutputPrice == nil && - result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil { - result = nil - } - } - memo[modelName] = result - return result -} diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 19e45a02db..42c2c46448 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -198,7 +198,8 @@ func TestChannelModelPricingClone(t *testing.T) { {MinTokens: 0, TierLabel: "tier1"}, }, TimePricing: &ChannelTimePricing{ - Timezone: "Asia/Shanghai", + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{{ StartTime: "09:00", EndTime: "12:00", @@ -217,9 +218,11 @@ func TestChannelModelPricingClone(t *testing.T) { require.Equal(t, "tier1", original.Intervals[0].TierLabel) cloned.TimePricing.Timezone = "America/New_York" + cloned.TimePricing.WeekdaysOnly = false cloned.TimePricing.Periods[0].StartTime = "10:00" cloned.TimePricing.Periods[0].Multiplier = 3 require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone) + require.True(t, original.TimePricing.WeekdaysOnly) require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime) require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier) } diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index e4d836febe..c7736f9582 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -139,7 +139,9 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case normalized == "grok" || strings.HasPrefix(normalized, "grok-"): return PlatformGrok, true - case strings.HasPrefix(normalized, "kimi-"), + case normalized == "k3", + normalized == "k3-256k", + strings.HasPrefix(normalized, "kimi-"), strings.HasPrefix(normalized, "moonshot-"): return PlatformKimi, true case strings.HasPrefix(normalized, "glm-"): diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index e302d3040c..bff5928024 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -170,9 +170,13 @@ func TestDetectModelPlatform(t *testing.T) { {name: "grok", model: "grok-4", platform: PlatformGrok, ok: true}, {name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true}, {name: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3", model: "K3", platform: PlatformKimi, ok: true}, + {name: "kimi code bare k3 256k", model: "k3-256k", platform: PlatformKimi, ok: true}, + {name: "kimi code provider prefix", model: "kimi-code/k3", platform: PlatformKimi, ok: true}, {name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true}, {name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true}, {name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true}, + {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 2c11388dd6..474b29387b 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -284,6 +284,22 @@ func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *te require.Nil(t, decision.Route) } +func TestCompositeRouteResolverDetectsKimiCodeBareModels(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + + for _, model := range []string{"k3", "k3-256k", "kimi-code/k3"} { + t.Run(model, func(t *testing.T) { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceDetector, decision.Source) + require.Equal(t, PlatformKimi, decision.TargetPlatform) + require.Equal(t, model, decision.UpstreamModel) + }) + } +} + func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/backend/internal/service/custom_channel_time_pricing.go b/backend/internal/service/custom_channel_time_pricing.go index 759eac987e..5b7ce8eb3d 100644 --- a/backend/internal/service/custom_channel_time_pricing.go +++ b/backend/internal/service/custom_channel_time_pricing.go @@ -47,6 +47,9 @@ func (config *ChannelTimePricing) MultiplierAt(at time.Time) float64 { } local := at.In(location) + if config.WeekdaysOnly && (local.Weekday() == time.Saturday || local.Weekday() == time.Sunday) { + return 1.0 + } second := local.Hour()*60*60 + local.Minute()*60 + local.Second() for _, period := range periods { if second >= period.start && second < period.end { diff --git a/backend/internal/service/custom_channel_time_pricing_test.go b/backend/internal/service/custom_channel_time_pricing_test.go index f1038af0d3..f36c19b7c9 100644 --- a/backend/internal/service/custom_channel_time_pricing_test.go +++ b/backend/internal/service/custom_channel_time_pricing_test.go @@ -95,6 +95,30 @@ func TestChannelTimePricingMultiplierAt(t *testing.T) { require.Equal(t, 2.0, newYork.MultiplierAt(at)) } +func TestChannelTimePricingMultiplierAtWeekdaysOnly(t *testing.T) { + config := &ChannelTimePricing{ + Timezone: "Asia/Shanghai", + WeekdaysOnly: true, + Periods: onePeriod(), + } + + tests := []struct { + name string + at time.Time + want float64 + }{ + {name: "Monday in configured timezone", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2}, + {name: "Saturday in configured timezone", at: time.Date(2026, 7, 4, 1, 0, 0, 0, time.UTC), want: 1}, + {name: "Sunday in configured timezone", at: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC), want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, config.MultiplierAt(tt.at)) + }) + } +} + func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) { config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2}) shanghai, err := time.LoadLocation("Asia/Shanghai") diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 0587c6167b..4679823777 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -517,6 +517,10 @@ const ( // the Model Plaza page (global pricing notes, exchange rate, promotions, ...). SettingKeyModelPlazaDescription = "model_plaza_description" + // SettingKeyPluginManagementEnabled controls sidebar visibility only; it does + // not stop or otherwise change already loaded plugin runtimes. + SettingKeyPluginManagementEnabled = "plugin_management_enabled" + // SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval // for probing remote Sub2API API-key billing metadata. SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings" diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 2fb7c4baa3..f4656bb15e 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1295,21 +1295,20 @@ func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testin } func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) { - usage := &ClaudeUsage{ - InputTokens: 10, - CacheCreation5mTokens: 2, - CacheCreation1hTokens: 6, - } - data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":1,"ephemeral_1h_input_tokens":0}}}` + usage := &ClaudeUsage{} + start := `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}` + parseSSEUsagePassthrough(start, usage) + + data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":463184,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}` parseSSEUsagePassthrough(data, usage) require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens") require.Equal(t, 5, usage.OutputTokens) - require.Equal(t, 8, usage.CacheCreationInputTokens) + require.Equal(t, 463184, usage.CacheCreationInputTokens) require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens") - require.Equal(t, 1, usage.CacheCreation5mTokens) - require.Equal(t, 6, usage.CacheCreation1hTokens, "message_delta 中 0 值不应覆盖已有 1h 明细") + require.Equal(t, 463184, usage.CacheCreation5mTokens) + require.Equal(t, 0, usage.CacheCreation1hTokens) } func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) { diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 533e780017..1da7eea92a 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -296,6 +296,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( UpstreamModel: input.RequestModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: input.RequestStream, Duration: time.Since(input.StartTime), FirstTokenMs: firstTokenMs, @@ -668,10 +669,10 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens") cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens") - if cc5m.Exists() && cc5m.Int() > 0 { + if cc5m.Exists() { usage.CacheCreation5mTokens = int(cc5m.Int()) } - if cc1h.Exists() && cc1h.Int() > 0 { + if cc1h.Exists() { usage.CacheCreation1hTokens = int(cc1h.Int()) } } @@ -697,6 +698,72 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { usage.CacheCreationInputTokens = int(total) } } + + // Kimi's Anthropic-compatible stream uses input_tokens with two meanings: + // message_start reports total prompt input, while message_delta reports only + // uncached input. prompt_tokens remains the total in both events. Normalize + // to ClaudeUsage's mutually-exclusive buckets so downstream billing does not + // subtract cache tokens from an already-uncached value. + usageNode := parsed.Get("usage") + if parsed.Get("type").String() == "message_start" { + usageNode = parsed.Get("message.usage") + } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) +} + +// normalizeAnthropicCompatiblePromptUsage converts provider-native OpenAI-style +// prompt/cache fields into Claude's mutually-exclusive usage buckets. Native +// Anthropic responses do not expose these aliases and are left alone. +func normalizeAnthropicCompatiblePromptUsage(usageNode gjson.Result, usage *ClaudeUsage) bool { + if usage == nil || !usageNode.Exists() { + return false + } + promptTokens := usageNode.Get("prompt_tokens") + promptCacheHitTokens := usageNode.Get("prompt_cache_hit_tokens") + promptCacheMissTokens := usageNode.Get("prompt_cache_miss_tokens") + if (!promptTokens.Exists() || promptTokens.Int() <= 0) && + !promptCacheHitTokens.Exists() && !promptCacheMissTokens.Exists() { + return false + } + + cacheReadTokens := usage.CacheReadInputTokens + if v := usageNode.Get("cache_read_input_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + if cacheReadTokens == 0 { + if v := usageNode.Get("cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 { + if v := usageNode.Get("prompt_tokens_details.cached_tokens"); v.Exists() { + cacheReadTokens = int(v.Int()) + } + } + if cacheReadTokens == 0 && promptCacheHitTokens.Exists() { + cacheReadTokens = max(int(promptCacheHitTokens.Int()), 0) + } + + cacheCreationTokens := usage.CacheCreationInputTokens + if v := usageNode.Get("cache_creation_input_tokens"); v.Exists() { + cacheCreationTokens = int(v.Int()) + } + if cacheCreationTokens == 0 { + cc5m := usageNode.Get("cache_creation.ephemeral_5m_input_tokens").Int() + cc1h := usageNode.Get("cache_creation.ephemeral_1h_input_tokens").Int() + if cc5m > 0 || cc1h > 0 { + cacheCreationTokens = int(cc5m + cc1h) + } + } + + if promptCacheMissTokens.Exists() { + usage.InputTokens = max(int(promptCacheMissTokens.Int()), 0) + } else { + usage.InputTokens = max(int(promptTokens.Int())-cacheReadTokens-cacheCreationTokens, 0) + } + usage.CacheReadInputTokens = cacheReadTokens + usage.CacheCreationInputTokens = cacheCreationTokens + return true } func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { @@ -730,6 +797,7 @@ func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage { usage.CacheReadInputTokens = int(cached) } } + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) return usage } diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index d8a105c7d8..17f9228d5d 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -888,6 +888,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A UpstreamModel: mappedModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: reqStream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -913,8 +914,9 @@ func anthropicSpeedModel(parsed *ParsedRequest, result *ForwardResult) string { // 承载(Opus 4.7 的 fast mode 已被移除,传 speed=fast 会直接报错)。这里按模型和 // 平台收紧,避免上游根本没跑 fast 时仍然按 2x 计费——宁可漏收也不能多收。 // -// 注:判据是请求参数而非响应里的 usage.speed。等 usage 解析链路统一暴露该字段后, -// 应改为以响应为准。 +// 这里只决定请求侧的档位;上游响应里的 usage.speed 由 UpstreamResponseServiceTier +// 带回,用量记录时经 ResolveBillingServiceTier 只降不升(usage.speed=standard 则按 +// 标准价计费)。 func anthropicSpeedServiceTier(account *Account, speed, model string) *string { if account == nil || account.Platform != PlatformAnthropic || speed != "fast" { return nil diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index cdd0197eab..6fcb1b2ed0 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -285,18 +285,46 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { if dst == nil { return } - if src.InputTokens > 0 { - dst.InputTokens = src.InputTokens + + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache + // fields. Prefer those authoritative totals or hit/miss buckets over the + // overloaded input_tokens field. This covers Kimi's changing stream + // semantics as well as GLM/DeepSeek cache aliases. + if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + + if src.PromptCacheMissTokens != nil { + dst.InputTokens = max(*src.PromptCacheMissTokens, 0) + } else { + dst.InputTokens = max(src.PromptTokens-cacheReadTokens-src.CacheCreationInputTokens, 0) + } + dst.CacheReadInputTokens = cacheReadTokens + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } else { + if src.InputTokens > 0 { + dst.InputTokens = src.InputTokens + } + if src.CacheReadInputTokens > 0 { + dst.CacheReadInputTokens = src.CacheReadInputTokens + } else if src.CachedTokens > 0 { + dst.CacheReadInputTokens = src.CachedTokens + } + if src.CacheCreationInputTokens > 0 { + dst.CacheCreationInputTokens = src.CacheCreationInputTokens + } } if src.OutputTokens > 0 { dst.OutputTokens = src.OutputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } - if src.CacheCreationInputTokens > 0 { - dst.CacheCreationInputTokens = src.CacheCreationInputTokens - } } // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 5361644745..0be0477029 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -694,3 +694,74 @@ func TestGatewayServiceRecordUsage_ReasoningEffortNil(t *testing.T) { require.NotNil(t, usageRepo.lastLog) require.Nil(t, usageRepo.lastLog.ReasoningEffort) } + +// newGatewayRecordUsageServiceWithResolverForTest mirrors production wiring for +// token billing: a pricing resolver plus a grouped API key select the unified +// billing path, which is the only one that honours the service tier. +func newGatewayRecordUsageServiceWithResolverForTest(usageRepo UsageLogRepository) (*GatewayService, *APIKey) { + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(7) + return svc, &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1.0}} +} + +func TestGatewayServiceRecordUsage_FastSpeedDowngradedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageBestEffortLogRepoStub{} + svc, apiKey := newGatewayRecordUsageServiceWithResolverForTest(usageRepo) + + tier := "fast" + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "fast_downgraded_test", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: "claude-opus-5", + Duration: time.Second, + ServiceTier: &tier, + UpstreamResponseServiceTier: "standard", + }, + APIKey: apiKey, + User: &User{ID: 1}, + Account: &Account{ID: 1, Platform: PlatformAnthropic}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "standard", *usageRepo.lastLog.ServiceTier) + + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + standardCost, err := svc.billingService.CalculateCost("claude-opus-5", tokens, 1.0) + require.NoError(t, err) + fastCost, err := svc.billingService.CalculateCostWithServiceTier("claude-opus-5", tokens, 1.0, "fast") + require.NoError(t, err) + require.Greater(t, fastCost.TotalCost, standardCost.TotalCost, "fast mode must carry a premium for the test to be meaningful") + require.InDelta(t, standardCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} + +func TestGatewayServiceRecordUsage_FastSpeedHonouredKeepsPremium(t *testing.T) { + usageRepo := &openAIRecordUsageBestEffortLogRepoStub{} + svc, apiKey := newGatewayRecordUsageServiceWithResolverForTest(usageRepo) + + tier := "fast" + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "fast_honoured_test", + Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}, + Model: "claude-opus-5", + Duration: time.Second, + ServiceTier: &tier, + UpstreamResponseServiceTier: "fast", + }, + APIKey: apiKey, + User: &User{ID: 1}, + Account: &Account{ID: 1, Platform: PlatformAnthropic}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "fast", *usageRepo.lastLog.ServiceTier) + + fastCost, err := svc.billingService.CalculateCostWithServiceTier("claude-opus-5", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0, "fast") + require.NoError(t, err) + require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index bc58ba41aa..8910cec117 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -619,13 +619,18 @@ type ForwardResult struct { // response before any client-facing rewrite or protocol conversion. UpstreamResponseModel string UpstreamResponseModelConflict bool - Stream bool - Duration time.Duration - FirstTokenMs *int // 首字时间(流式请求) - ClientDisconnect bool // 客户端是否在流式传输过程中断开 - ReasoningEffort *string - // ServiceTier records the billable request tier. OpenAI uses service_tier; - // Anthropic speed=fast is normalized to "fast". + // UpstreamResponseServiceTier is the tier the upstream reports having used + // (Anthropic usage.speed: "fast" / "standard"); "" when not declared. + UpstreamResponseServiceTier string + Stream bool + Duration time.Duration + FirstTokenMs *int // 首字时间(流式请求) + ClientDisconnect bool // 客户端是否在流式传输过程中断开 + ReasoningEffort *string + // ServiceTier records the tier requested by the client. OpenAI uses + // service_tier; Anthropic speed=fast is normalized to "fast". Usage recording + // lowers it to UpstreamResponseServiceTier when the upstream reports a + // cheaper tier (see ResolveBillingServiceTier). ServiceTier *string // 图片生成计费字段(图片生成模型使用) @@ -1597,19 +1602,19 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) { } // 如果 path 指向一个已存在的目录,自动追加默认文件名 - if info, err := os.Stat(path); err == nil && info.IsDir() { + if info, err := os.Stat(path); err == nil && info.IsDir() { //nolint:gosec // G703: path 仅来自启动环境变量 SUB2API_DEBUG_GATEWAY_BODY(运维配置),非请求输入 path = filepath.Join(path, debugGatewayBodyDefaultFilename) } // 确保父目录存在 if dir := filepath.Dir(path); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0755); err != nil { //nolint:gosec // G703: 同上 slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err) return } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) //nolint:gosec // G703: 同上 if err != nil { slog.Error("failed to open gateway debug log file", "path", path, "error", err) return diff --git a/backend/internal/service/gateway_streaming_test.go b/backend/internal/service/gateway_streaming_test.go index df8e8e4f80..cf0adfaa24 100644 --- a/backend/internal/service/gateway_streaming_test.go +++ b/backend/internal/service/gateway_streaming_test.go @@ -82,20 +82,19 @@ func TestParseSSEUsage_DeltaOverwritesWithNonZero(t *testing.T) { require.Equal(t, 60, usage.CacheReadInputTokens) } -func TestParseSSEUsage_DeltaDoesNotResetCacheCreationBreakdown(t *testing.T) { +func TestParseSSEUsage_DeltaAuthoritativelyUpdatesCacheCreationBreakdown(t *testing.T) { svc := newMinimalGatewayService() usage := &ClaudeUsage{} - // 先在 message_start 中写入非零 5m/1h 明细 - svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}}}`, usage) - require.Equal(t, 30, usage.CacheCreation5mTokens) - require.Equal(t, 70, usage.CacheCreation1hTokens) + svc.parseSSEUsage(`{"type":"message_start","message":{"usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`, usage) + require.Equal(t, 463184, usage.CacheCreationInputTokens) + require.Equal(t, 0, usage.CacheCreation5mTokens) + require.Equal(t, 463184, usage.CacheCreation1hTokens) - // 后续 delta 带默认 0,不应覆盖已有非零值 - svc.parseSSEUsage(`{"type":"message_delta","usage":{"output_tokens":12,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0}}}`, usage) - require.Equal(t, 30, usage.CacheCreation5mTokens, "delta 的 0 值不应重置 5m 明细") - require.Equal(t, 70, usage.CacheCreation1hTokens, "delta 的 0 值不应重置 1h 明细") - require.Equal(t, 12, usage.OutputTokens) + svc.parseSSEUsage(`{"type":"message_delta","usage":{"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`, usage) + require.Equal(t, 463184, usage.CacheCreationInputTokens) + require.Equal(t, 463184, usage.CacheCreation5mTokens) + require.Equal(t, 0, usage.CacheCreation1hTokens) } func TestParseSSEUsage_InvalidJSON(t *testing.T) { diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 20af32314f..5663333a91 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -680,6 +680,7 @@ func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: streamResult.firstTokenMs, @@ -1236,11 +1237,11 @@ func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePat patch.hasCacheReadInput = true } if cc, ok := usageObj["cache_creation"].(map[string]any); ok { - if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists && v > 0 { + if v, exists := parseSSEUsageInt(cc["ephemeral_5m_input_tokens"]); exists { patch.cacheCreation5mTokens = v patch.hasCacheCreation5m = true } - if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists && v > 0 { + if v, exists := parseSSEUsageInt(cc["ephemeral_1h_input_tokens"]); exists { patch.cacheCreation1hTokens = v patch.hasCacheCreation1h = true } diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index e1fac3d6cf..1832dd6d32 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -776,6 +776,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage account := input.Account subscription := input.Subscription ApplyForwardImageBillingResolution(result) + logServiceTierBillingDowngrade("service.gateway", account, result.RequestID, ApplyForwardServiceTierBillingResolution(result)) // 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens // 用于粘性会话切换时的特殊计费处理 @@ -1121,7 +1122,8 @@ func (s *GatewayService) calculateImageCost( return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } -// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。 +// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价) +// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。 func (s *GatewayService) calculateTokenCost( ctx context.Context, result *ForwardResult, @@ -1141,39 +1143,28 @@ func (s *GatewayService) calculateTokenCost( ImageOutputTokens: result.Usage.ImageOutputTokens, } - var cost *CostBreakdown - var err error - - // Explicit group/channel pricing wins. Built-in pricing also uses the unified - // resolver so the group long-context toggle can veto model-native tiers. - if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { + var resolved *ResolvedPricing + if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID - cost, err = s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, - Model: billingModel, - GroupID: &gid, - Group: apiKey.Group, - Tokens: tokens, - RequestCount: 1, - RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), - Resolver: s.resolver, - Resolved: resolved, - }) - } else if opts.LongContextThreshold > 0 && (apiKey.Group == nil || apiKey.Group.LongContextPricingEnabled) { - // 长上下文双倍计费(如 Gemini 200K 阈值) - cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) - } else if s.resolver != nil && apiKey.Group != nil { - gid := apiKey.Group.ID - cost, err = s.billingService.CalculateCostUnified(CostInput{ - Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), Resolver: s.resolver, - }) - } else { - cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) + resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) } + var legacy *LegacyLongContextRule + if opts.LongContextThreshold > 0 { + legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier} + } + + cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + Resolver: s.resolver, + Resolved: resolved, + LegacyLongContext: legacy, + }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) return &CostBreakdown{ActualCost: 0} @@ -1181,6 +1172,14 @@ func (s *GatewayService) calculateTokenCost( return cost } +// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。 +func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule { + if s == nil || s.billingService == nil { + return nil + } + return s.billingService.LegacyLongContextRule(platform) +} + // buildRecordUsageLog 构建使用日志并设置计费模式。 func (s *GatewayService) buildRecordUsageLog( ctx context.Context, diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 82cf9a20f3..5f9854167d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -3587,12 +3587,20 @@ func cleanToolSchema(schema any) any { if key == "$schema" || key == "$id" || key == "$ref" || key == "$defs" || key == "definitions" || key == "additionalProperties" || key == "patternProperties" || key == "minLength" || - key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" { + key == "maxLength" || key == "minItems" || key == "maxItems" || key == "exclusiveMinimum" || + key == "deprecated" { continue } // 递归清理嵌套对象 cleaned[key] = cleanToolSchema(value) } + if enum, ok := cleaned["enum"].([]any); ok { + if normalized, ok := normalizeGeminiEnum(enum); ok { + cleaned["enum"] = normalized + } else { + delete(cleaned, "enum") + } + } // 规范化 type 字段为大写 if typeVal, ok := cleaned["type"].(string); ok { cleaned["type"] = strings.ToUpper(typeVal) @@ -3627,6 +3635,28 @@ func cleanToolSchema(schema any) any { } } +func normalizeGeminiEnum(values []any) ([]any, bool) { + normalized := make([]any, len(values)) + for i, value := range values { + if stringValue, ok := value.(string); ok { + normalized[i] = stringValue + continue + } + + switch value.(type) { + case nil, bool, float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number: + encoded, err := json.Marshal(value) + if err != nil { + return nil, false + } + normalized[i] = string(encoded) + default: + return nil, false + } + } + return normalized, true +} + func incrementIntegralSchemaBound(value any) (any, bool) { switch v := value.(type) { case float64: diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index 2023edc979..e9ee74e3be 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -471,6 +471,44 @@ func TestCleanToolSchema_DropsAmbiguousExclusiveMinimumWithoutConversion(t *test } } +func TestCleanToolSchema_RemovesNestedDeprecatedAndNormalizesMixedScalarEnum(t *testing.T) { + schema := map[string]any{ + "anyOf": []any{ + map[string]any{ + "type": "string", + "deprecated": true, + }, + map[string]any{ + "enum": []any{"enabled", false, float64(1), nil}, + }, + }, + } + + cleaned, ok := cleanToolSchema(schema).(map[string]any) + require.True(t, ok) + anyOf, ok := cleaned["anyOf"].([]any) + require.True(t, ok) + require.Len(t, anyOf, 2) + + deprecatedSchema, ok := anyOf[0].(map[string]any) + require.True(t, ok) + require.NotContains(t, deprecatedSchema, "deprecated") + + enumSchema, ok := anyOf[1].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"enabled", "false", "1", "null"}, enumSchema["enum"]) +} + +func TestCleanToolSchema_DropsEnumWithNonScalarValue(t *testing.T) { + schema := map[string]any{ + "enum": []any{"valid", map[string]any{"invalid": true}}, + } + + cleaned, ok := cleanToolSchema(schema).(map[string]any) + require.True(t, ok) + require.NotContains(t, cleaned, "enum") +} + func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { tools := []any{ map[string]any{ diff --git a/backend/internal/service/grok_observed_models.go b/backend/internal/service/grok_observed_models.go index 480ba457bd..3dba5f0013 100644 --- a/backend/internal/service/grok_observed_models.go +++ b/backend/internal/service/grok_observed_models.go @@ -92,7 +92,7 @@ func (s *GrokQuotaService) syncGrokObservedModels(ctx context.Context, account * } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", grokUpstreamUserAgent) + req.Header.Set("User-Agent", defaultGrokUpstreamUserAgent()) if account.IsGrokOAuth() { applyGrokCLIHeaders(req.Header) if isGrokCLIProxyTarget(req.URL.String()) { diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index 70ee2c8d72..af27b67f12 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -133,8 +133,9 @@ func TestSyncGrokObservedModelsUsesCLIIdentityAndAccountHeaders(t *testing.T) { require.NoError(t, svc.syncGrokObservedModels(context.Background(), account)) require.Equal(t, xai.DefaultCLIBaseURL+"/models", upstream.lastReq.URL.String()) - require.NotEmpty(t, upstream.lastReq.Header.Get("x-grok-client-version")) + require.Equal(t, xai.CLIClientVersion, upstream.lastReq.Header.Get("x-grok-client-version")) require.Equal(t, xai.CLIClientIdentifier, upstream.lastReq.Header.Get("x-grok-client-identifier")) + require.Equal(t, xai.CLIUserAgent(xai.CLIClientVersion), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) require.Equal(t, "user-902", upstream.lastReq.Header.Get("X-UserID")) require.Equal(t, "user902@example.test", upstream.lastReq.Header.Get("X-Email")) @@ -301,6 +302,22 @@ func (u *grokHybridUpstream) snapshot() ([]*http.Request, [][]byte) { return requests, bodies } +// quotaSnapshot 返回配额探测链路的请求及请求体,不含 scheduleGrokObservedModelsSync +// 在 QueryQuota 返回后异步发出的 GET /v1/models:该请求是否已落到上游取决于调度时序。 +func (u *grokHybridUpstream) quotaSnapshot() ([]*http.Request, [][]byte) { + requests, bodies := u.snapshot() + quotaRequests := make([]*http.Request, 0, len(requests)) + quotaBodies := make([][]byte, 0, len(bodies)) + for i, req := range requests { + if req.URL.Path == "/v1/models" { + continue + } + quotaRequests = append(quotaRequests, req) + quotaBodies = append(quotaBodies, bodies[i]) + } + return quotaRequests, quotaBodies +} + func (r *grokQuotaProxyRepo) GetByID(_ context.Context, id int64) (*Proxy, error) { r.calls++ return r.proxies[id], nil @@ -741,7 +758,7 @@ func TestGrokQuotaServiceQueryQuotaFreeFallsBackToGrok45(t *testing.T) { require.EqualValues(t, 2_000_000, *result.Snapshot.Tokens.Limit) require.True(t, result.HeadersObserved) - requests, bodies := upstream.snapshot() + requests, bodies := upstream.quotaSnapshot() require.Len(t, requests, 3) responseCalls := 0 for i, req := range requests { @@ -781,7 +798,7 @@ func TestGrokQuotaServiceQueryQuotaPaidBillingSkipsActiveProbe(t *testing.T) { require.Empty(t, result.Model) require.Nil(t, result.LocalUsage24h) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) @@ -806,7 +823,7 @@ func TestGrokQuotaServiceQueryQuotaCustomPaidMonthlyLimitSkipsActiveProbe(t *tes require.InDelta(t, monthlyLimit, *result.Billing.MonthlyLimitCents, 1e-9) require.Nil(t, result.Snapshot) - requests, _ := upstream.snapshot() + requests, _ := upstream.quotaSnapshot() require.Len(t, requests, 2) for _, req := range requests { require.Equal(t, "/v1/billing", req.URL.Path) diff --git a/backend/internal/service/grok_upstream_headers.go b/backend/internal/service/grok_upstream_headers.go index cdfb54bcbf..4b24e4c13b 100644 --- a/backend/internal/service/grok_upstream_headers.go +++ b/backend/internal/service/grok_upstream_headers.go @@ -10,10 +10,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) -// grokUpstreamUserAgent is kept for compatibility with older Grok request -// tests. Current requests use the pinned default UA from this package. -const grokUpstreamUserAgent = "sub2api-grok/1.0" - // Fixed CLI identity aliases — single source of truth is internal/pkg/xai. const ( grokClientVersionHeader = xai.CLIStableVersion diff --git a/backend/internal/service/kimi_anthropic_usage_test.go b/backend/internal/service/kimi_anthropic_usage_test.go new file mode 100644 index 0000000000..458ba9fb39 --- /dev/null +++ b/backend/internal/service/kimi_anthropic_usage_test.go @@ -0,0 +1,248 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +func TestParseSSEUsagePassthroughNormalizesKimiPromptUsage(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"prompt_tokens":173306,"cached_tokens":0}}}`, usage) + require.Equal(t, 173306, usage.InputTokens) + require.Zero(t, usage.CacheReadInputTokens) + + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":250,"cache_creation_input_tokens":0,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, usage) + require.Equal(t, 250, usage.InputTokens, "Kimi message_delta input_tokens is already the uncached bucket") + require.Equal(t, 173056, usage.CacheReadInputTokens) + require.Equal(t, 166, usage.OutputTokens) +} + +func TestParseSSEUsagePassthroughKimiFullyCachedInputReplacesStartTotal(t *testing.T) { + usage := &ClaudeUsage{} + + parseSSEUsagePassthrough(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306}}}`, usage) + parseSSEUsagePassthrough(`{"type":"message_delta","usage":{"input_tokens":0,"cache_read_input_tokens":173306,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306}}`, usage) + + require.Zero(t, usage.InputTokens, "an explicit zero uncached bucket must not retain message_start's total") + require.Equal(t, 173306, usage.CacheReadInputTokens) +} + +func TestParseClaudeUsageFromResponseBodyNormalizesCNProviderAliases(t *testing.T) { + tests := []struct { + name string + body string + wantInput int + wantCacheRead int + wantOutput int + }{ + { + name: "Kimi top-level cached_tokens", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"cache_read_input_tokens":173056,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + wantCacheRead: 173056, + wantOutput: 166, + }, + { + name: "GLM nested prompt cache details", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + { + name: "DeepSeek prompt cache hit and miss buckets", + body: `{"usage":{"input_tokens":1200,"output_tokens":300,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + wantOutput: 300, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, tt.wantOutput, usage.OutputTokens) + }) + } +} + +func TestParseSSEUsagePassthroughNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + data string + wantInput int + wantCacheRead int + }{ + { + name: "GLM", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + wantCacheRead: 800, + }, + { + name: "DeepSeek", + data: `{"type":"message_delta","usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + wantCacheRead: 800, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := &ClaudeUsage{} + parseSSEUsagePassthrough(tt.data, usage) + require.Equal(t, tt.wantInput, usage.InputTokens) + require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens) + require.Equal(t, 30, usage.OutputTokens) + }) + } +} + +func TestMergeAnthropicUsageNormalizesKimiStreamForOpenAIBilling(t *testing.T) { + var start apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":173306,"prompt_tokens":173306,"cached_tokens":0}}}`), &start)) + var delta apicompat.AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(`{"type":"message_delta","usage":{"input_tokens":250,"cache_read_input_tokens":173056,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`), &delta)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, start.Message.Usage) + mergeAnthropicUsage(usage, *delta.Usage) + require.Equal(t, 250, usage.InputTokens) + require.Equal(t, 173056, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 173306, openAIUsage.InputTokens, "OpenAI gateway expects an inclusive input total") + require.Equal(t, 250, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, 166, openAIUsage.OutputTokens) +} + +func TestMergeAnthropicUsageNormalizesGLMAndDeepSeekAliases(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "GLM", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`, + }, + { + name: "DeepSeek", + raw: `{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var src apicompat.AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(tt.raw), &src)) + + usage := &ClaudeUsage{} + mergeAnthropicUsage(usage, src) + require.Equal(t, 400, usage.InputTokens) + require.Equal(t, 800, usage.CacheReadInputTokens) + + openAIUsage := claudeUsageToOpenAIUsage(usage) + require.Equal(t, 1200, openAIUsage.InputTokens) + require.Equal(t, 400, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestClaudeUsageToOpenAIUsagePreservesCNProviderNativeAnthropicBuckets(t *testing.T) { + tests := []struct { + name string + usage ClaudeUsage + wantTotal int + wantUncached int + }{ + { + name: "GLM", + usage: ClaudeUsage{ + InputTokens: 2, + OutputTokens: 302, + CacheCreationInputTokens: 733, + CacheReadInputTokens: 376156, + }, + wantTotal: 376891, + wantUncached: 2, + }, + { + name: "DeepSeek", + usage: ClaudeUsage{ + InputTokens: 400, + OutputTokens: 30, + CacheReadInputTokens: 800, + }, + wantTotal: 1200, + wantUncached: 400, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + openAIUsage := claudeUsageToOpenAIUsage(&tt.usage) + require.Equal(t, tt.wantTotal, openAIUsage.InputTokens) + require.Equal(t, tt.wantUncached, openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens) + require.Equal(t, tt.usage.CacheReadInputTokens, openAIUsage.CacheReadInputTokens) + require.Equal(t, tt.usage.CacheCreationInputTokens, openAIUsage.CacheCreationInputTokens) + }) + } +} + +func TestCNProviderAnthropicUsageBillsUncachedInput(t *testing.T) { + tests := []struct { + name string + model string + body string + wantInput int + }{ + { + name: "Kimi", + model: "k3", + body: `{"usage":{"input_tokens":173306,"output_tokens":166,"prompt_tokens":173306,"cached_tokens":173056}}`, + wantInput: 250, + }, + { + name: "GLM", + model: "glm-5.2", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}}`, + wantInput: 400, + }, + { + name: "DeepSeek", + model: "deepseek-v4-flash", + body: `{"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}}`, + wantInput: 400, + }, + } + + billing := NewBillingService(&config.Config{}, nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claudeUsage := parseClaudeUsageFromResponseBody([]byte(tt.body)) + openAIUsage := claudeUsageToOpenAIUsage(claudeUsage) + uncachedInput := max(openAIUsage.InputTokens-openAIUsage.CacheReadInputTokens-openAIUsage.CacheCreationInputTokens, 0) + require.Equal(t, tt.wantInput, uncachedInput) + + cost, err := billing.CalculateCost(tt.model, UsageTokens{ + InputTokens: uncachedInput, + OutputTokens: openAIUsage.OutputTokens, + CacheCreationTokens: openAIUsage.CacheCreationInputTokens, + CacheReadTokens: openAIUsage.CacheReadInputTokens, + }, 1) + require.NoError(t, err) + require.Positive(t, cost.InputCost, "uncached input must contribute to the final charge") + + pricing, err := billing.GetModelPricing(tt.model) + require.NoError(t, err) + require.InDelta(t, float64(tt.wantInput)*pricing.InputPricePerToken, cost.InputCost, 1e-12) + }) + } +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go new file mode 100644 index 0000000000..fe2252c594 --- /dev/null +++ b/backend/internal/service/model_plaza_service.go @@ -0,0 +1,364 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// PlazaOfficialPricing 模型广场展示用的官方参考价(USD per token),与计费同源: +// LiteLLM → 内置兜底价卡 → 模型策略。字段为 nil 表示该项缺失(0 视为未配置)。 +type PlazaOfficialPricing struct { + InputPrice *float64 + OutputPrice *float64 + CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation) + CacheWrite1hPrice *float64 // 1h 缓存写入,仅计费会区分 5m/1h 时给出 + CacheReadPrice *float64 + // Intervals 官方长上下文阶梯(多档时给出),不受分组开关影响。 + Intervals []PricingInterval +} + +// PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。 +type PlazaModel struct { + Name string + Platform string + Pricing *ChannelModelPricing + OfficialPricing *PlazaOfficialPricing + // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。 + LongContextBasis ContextPricingBasis + // TimePricing 计费会生效的分时倍率时段;无分时为 nil。 + TimePricing *TimePricingSchedule +} + +// PlazaGroup 模型广场中以分组为顶层的条目。 +// +// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的 +// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的 +// 具体平台),与「可用渠道」页口径一致。 +type PlazaGroup struct { + ID int64 + Name string + Description string + Platform string + SubscriptionType string + RateMultiplier float64 + PeakRateEnabled bool + PeakStart string + PeakEnd string + PeakRateMultiplier float64 + IsExclusive bool + // 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付 + // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 + ImageRateIndependent bool + ImageRateMultiplier float64 + // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。 + LongContextPricingEnabled bool + Models []PlazaModel +} + +// ModelPlazaService 聚合模型广场数据。 +// +// 模型枚举来自渠道配置;token 模型的展示单价与阶梯由 BillingService 的阶梯表 +// 查询给出(与扣费走同一条解析链与计费函数),图片/按次模型沿用渠道/分组档位价。 +type ModelPlazaService struct { + channelRepo ChannelRepository + groupRepo GroupRepository + pricingService *PricingService + billingService *BillingService + resolver *ModelPricingResolver +} + +// NewModelPlazaService 创建模型广场服务。 +func NewModelPlazaService( + channelRepo ChannelRepository, + groupRepo GroupRepository, + pricingService *PricingService, + billingService *BillingService, + resolver *ModelPricingResolver, +) *ModelPlazaService { + return &ModelPlazaService{ + channelRepo: channelRepo, + groupRepo: groupRepo, + pricingService: pricingService, + billingService: billingService, + resolver: resolver, + } +} + +// ListGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。 +// +// 模型枚举口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、 +// 平台隔离),仅把顶层从渠道换成分组: +// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定; +// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换; +// - token 模型的单价与阶梯按实收口径合成(见 ResolveContextPricingSchedule), +// 图片计费模型的档位价按实收口径合成(见 plazaImageDisplayPricing); +// - 每个模型附带官方参考价(查不到为 nil); +// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称), +// 组内模型按名称排序。 +// +// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。 +func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error) { + channels, err := s.channelRepo.ListAll(ctx) + if err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + groups, err := s.groupRepo.ListActive(ctx) + if err != nil { + return nil, fmt.Errorf("list active groups: %w", err) + } + + sort.SliceStable(channels, func(i, j int) bool { + return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name) + }) + + byGroup := make(map[int64]*PlazaGroup, len(groups)) + groupEnt := make(map[int64]*Group, len(groups)) + order := make([]int64, 0, len(groups)) + for i := range groups { + g := &groups[i] + byGroup[g.ID] = &PlazaGroup{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, + LongContextPricingEnabled: g.LongContextPricingEnabled, + } + groupEnt[g.ID] = g + order = append(order, g.ID) + } + + type modelKey struct { + platform string + name string + } + // modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models + modelIdx := make(map[int64]map[modelKey]int, len(groups)) + for i := range channels { + ch := &channels[i] + if ch.Status != StatusActive { + continue + } + ch.normalizeBillingModelSource() + supported := ch.SupportedModels() + fillGlobalPricingFallback(s.pricingService, supported) + + for _, gid := range ch.GroupIDs { + pg, ok := byGroup[gid] + if !ok { + continue + } + idx := modelIdx[gid] + if idx == nil { + idx = make(map[modelKey]int, len(supported)) + modelIdx[gid] = idx + } + for j := range supported { + m := supported[j] + if pg.Platform == PlatformComposite { + if !isConcreteRequestPlatform(m.Platform) { + continue + } + } else if m.Platform != pg.Platform { + continue + } + key := modelKey{platform: m.Platform, name: m.Name} + if at, seen := idx[key]; seen { + // 先见者胜;仅当已存条目无定价而新条目有定价时升级。 + if pg.Models[at].Pricing == nil && m.Pricing != nil { + pg.Models[at].Pricing = m.Pricing + } + continue + } + idx[key] = len(pg.Models) + pg.Models = append(pg.Models, PlazaModel{ + Name: m.Name, + Platform: m.Platform, + Pricing: m.Pricing, + }) + } + } + } + + officialMemo := make(map[string]*PlazaOfficialPricing) + out := make([]PlazaGroup, 0, len(order)) + for _, gid := range order { + pg := byGroup[gid] + if len(pg.Models) == 0 { + continue + } + sort.SliceStable(pg.Models, func(i, j int) bool { + if pg.Models[i].Name != pg.Models[j].Name { + return pg.Models[i].Name < pg.Models[j].Name + } + return pg.Models[i].Platform < pg.Models[j].Platform + }) + g := groupEnt[gid] + for j := range pg.Models { + s.fillDisplayPricing(ctx, &pg.Models[j], g) + pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo) + } + out = append(out, *pg) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].RateMultiplier != out[j].RateMultiplier { + return out[i].RateMultiplier < out[j].RateMultiplier + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// fillDisplayPricing 把模型的展示定价换成实收口径: +// token 模型取计费阶梯表(单价与档位均由真实计费函数得出), +// 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。 +func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) { + if s.billingService != nil && s.resolver != nil { + sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{ + Model: m.Name, + Group: g, + Platform: m.Platform, + }) + if err == nil && sched != nil && len(sched.Tiers) > 0 { + m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + if len(sched.Tiers) > 1 { + m.LongContextBasis = sched.Basis + } + m.TimePricing = sched.TimePricing + return + } + } + m.Pricing = plazaImageDisplayPricing(m.Pricing, g) +} + +// plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: +// 平价取首档单价,多档时 Intervals 逐档给出绝对单价;图片/按次字段沿用原始定价。 +func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSchedule) *ChannelModelPricing { + out := &ChannelModelPricing{BillingMode: BillingModeToken} + if raw != nil { + out.ImageInputPrice = raw.ImageInputPrice + out.ImageOutputPrice = raw.ImageOutputPrice + out.PerRequestPrice = raw.PerRequestPrice + } + first := sched.Tiers[0] + out.InputPrice = first.Input + out.OutputPrice = first.Output + out.CacheWritePrice = first.CacheWrite + out.CacheReadPrice = first.CacheRead + if len(sched.Tiers) > 1 { + out.Intervals = plazaIntervalsFromTiers(sched.Tiers) + } + return out +} + +func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval { + intervals := make([]PricingInterval, 0, len(tiers)) + for i, t := range tiers { + intervals = append(intervals, PricingInterval{ + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + TierLabel: t.Label, + InputPrice: t.Input, + OutputPrice: t.Output, + CacheWritePrice: t.CacheWrite, + CacheReadPrice: t.CacheRead, + SortOrder: i, + }) + } + return intervals +} + +// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致: +// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。 +// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参 +// (渠道定价指针指向缓存共享数据)。 +func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing { + if p == nil || g == nil || p.BillingMode != BillingModeImage { + return p + } + if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil { + return p + } + channelTierPrice := func(label string) *float64 { + for i := range p.Intervals { + if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil { + return p.Intervals[i].PerRequestPrice + } + } + return p.PerRequestPrice + } + tiers := []struct { + label string + groupPrice *float64 + }{ + {"1K", g.ImagePrice1K}, + {"2K", g.ImagePrice2K}, + {"4K", g.ImagePrice4K}, + } + clone := *p + clone.Intervals = make([]PricingInterval, 0, len(tiers)) + for i, t := range tiers { + price := t.groupPrice + if price == nil { + price = channelTierPrice(t.label) + } + if price == nil { + continue + } + v := *price + clone.Intervals = append(clone.Intervals, PricingInterval{ + TierLabel: t.label, + PerRequestPrice: &v, + SortOrder: i, + }) + } + return &clone +} + +// lookupOfficialPricing 查询模型的官方参考价(与计费同源:LiteLLM → 内置兜底 → 模型策略), +// 带 memo 避免同名模型重复解析。官方阶梯按无分组、无渠道的口径查阶梯表。 +// billingService 为 nil(测试场景)或查不到时返回 nil。 +func (s *ModelPlazaService) lookupOfficialPricing(ctx context.Context, modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing { + if s.billingService == nil { + return nil + } + if cached, ok := memo[modelName]; ok { + return cached + } + var result *PlazaOfficialPricing + if mp, err := s.billingService.GetModelPricing(modelName); err == nil && mp != nil { + result = &PlazaOfficialPricing{ + InputPrice: nonZeroPtr(mp.InputPricePerToken), + OutputPrice: nonZeroPtr(mp.OutputPricePerToken), + CacheWritePrice: nonZeroPtr(mp.CacheCreationPricePerToken), + CacheReadPrice: nonZeroPtr(mp.CacheReadPricePerToken), + } + // 计费只在支持 5m/1h 分档时使用 1h 价,其余情况 1h 价对用户无意义。 + if mp.SupportsCacheBreakdown { + result.CacheWrite1hPrice = nonZeroPtr(mp.CacheCreation1hPrice) + } + if s.resolver != nil { + sched, schedErr := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{Model: modelName}) + if schedErr == nil && sched != nil && len(sched.Tiers) > 1 { + result.Intervals = plazaIntervalsFromTiers(sched.Tiers) + } + } + if result.InputPrice == nil && result.OutputPrice == nil && result.CacheWritePrice == nil && + result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil && len(result.Intervals) == 0 { + result = nil + } + } + memo[modelName] = result + return result +} diff --git a/backend/internal/service/channel_plaza_test.go b/backend/internal/service/model_plaza_service_test.go similarity index 53% rename from backend/internal/service/channel_plaza_test.go rename to backend/internal/service/model_plaza_service_test.go index 554654149c..d54aca70ad 100644 --- a/backend/internal/service/channel_plaza_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -7,17 +7,16 @@ import ( "errors" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) -// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。 -func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService { +// newPlazaService 构造 ListGroups 测试用的 ModelPlazaService(不接计费服务:展示定价原样透传)。 +func newPlazaService(channels []Channel, groups []Group, pricing *PricingService) *ModelPlazaService { repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, } - svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil) - svc.pricingService = pricing - return svc + return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, pricing, nil, nil) } func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel { @@ -46,8 +45,8 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { {ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1}, {ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1, "无模型的分组不应返回") require.Equal(t, int64(10), out[0].ID) @@ -71,8 +70,8 @@ func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} // alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。 - svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{priced, unpriced}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) require.Len(t, out[0].Models, 1) @@ -93,8 +92,8 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) { {ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1}, {ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1}, } - svc := newPlazaChannelService([]Channel{ch}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{ch}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) byName := map[string][]PlazaModel{} @@ -122,7 +121,7 @@ func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing } groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}} - out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) @@ -146,7 +145,7 @@ func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing {ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}, } - out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background()) + out, err := newPlazaService([]Channel{ch}, groups, nil).ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) @@ -173,8 +172,8 @@ func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) { inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet") inactive.Status = "inactive" groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} - svc := newPlazaChannelService([]Channel{inactive}, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService([]Channel{inactive}, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Empty(t, out) } @@ -188,8 +187,8 @@ func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) { {ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1}, {ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 3) require.Equal(t, "cheap", out[0].Name, "倍率低者在前") @@ -213,8 +212,11 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) { plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"), } groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}} - svc := newPlazaChannelService(channels, groups, pricingSvc) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, pricingSvc) + // 官方价与计费同源:需要计费服务与解析器(官方参考不查渠道,解析器无需渠道服务)。 + svc.billingService = NewBillingService(&config.Config{}, pricingSvc) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) require.Len(t, out[0].Models, 3) @@ -256,8 +258,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1}, {ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1}, } - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) byName := map[string]PlazaGroup{} @@ -298,8 +300,8 @@ func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) { imgPrice := 0.02 channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")} groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}} - svc := newPlazaChannelService(channels, groups, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := newPlazaService(channels, groups, nil) + out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) p := out[0].Models[0].Pricing @@ -314,17 +316,180 @@ func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) { repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, } - svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil) - out, err := svc.ListPlazaGroups(context.Background()) + svc := NewModelPlazaService(repo, &stubGroupRepoForAvailable{}, nil, nil, nil) + out, err := svc.ListGroups(context.Background()) require.Nil(t, out) require.ErrorIs(t, err, sentinel) - svc2 := NewChannelService( + svc2 := NewModelPlazaService( &mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }}, &stubGroupRepoForAvailable{listActiveErr: sentinel}, - nil, nil, + nil, nil, nil, ) - out2, err2 := svc2.ListPlazaGroups(context.Background()) + out2, err2 := svc2.ListGroups(context.Background()) require.Nil(t, out2) require.ErrorIs(t, err2, sentinel) } + +// newPlazaServiceWithBilling 构造接入计费服务与解析器的广场服务:解析器的渠道服务与广场共用同一份渠道数据。 +func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatforms map[int64]string, catalog *PricingService) *ModelPlazaService { + repo := &mockChannelRepository{ + listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, + getGroupPlatformsFn: func(ctx context.Context, _ []int64) (map[int64]string, error) { + return groupPlatforms, nil + }, + } + cs := NewChannelService(repo, nil, nil, nil) + bs := NewBillingService(&config.Config{}, catalog) + return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs)) +} + +func plazaModelsByName(models []PlazaModel) map[string]PlazaModel { + out := make(map[string]PlazaModel, len(models)) + for _, m := range models { + out[m.Name] = m + } + return out +} + +func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { + // 同一渠道挂开启/关闭阶梯的两个分组:实付档位随分组开关,官方阶梯不受影响。 + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10, 20}, + ModelPricing: []ChannelModelPricing{{Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken}}, + }} + groups := []Group{ + {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true}, + {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false}, + } + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, out, 2) + + on, off := out[0], out[1] + require.True(t, on.LongContextPricingEnabled) + require.False(t, off.LongContextPricingEnabled) + + onModel := on.Models[0] + require.Equal(t, ContextPricingBasisWholeRequest, onModel.LongContextBasis) + require.Len(t, onModel.Pricing.Intervals, 2) + require.Equal(t, "≤272K", onModel.Pricing.Intervals[0].TierLabel) + require.Equal(t, ">272K", onModel.Pricing.Intervals[1].TierLabel) + require.InDelta(t, 2.5e-6, *onModel.Pricing.InputPrice, 1e-15) + require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 22.5e-6, *onModel.Pricing.Intervals[1].OutputPrice, 1e-15) + require.InDelta(t, 5e-6, *onModel.Pricing.Intervals[1].CacheWritePrice, 1e-15) + require.InDelta(t, 0.5e-6, *onModel.Pricing.Intervals[1].CacheReadPrice, 1e-15) + + offModel := off.Models[0] + require.Empty(t, offModel.LongContextBasis) + require.Empty(t, offModel.Pricing.Intervals) + require.InDelta(t, 2.5e-6, *offModel.Pricing.InputPrice, 1e-15) + + for _, m := range []PlazaModel{onModel, offModel} { + require.NotNil(t, m.OfficialPricing) + require.Len(t, m.OfficialPricing.Intervals, 2, "官方阶梯不受分组开关影响") + require.InDelta(t, 5e-6, *m.OfficialPricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 2.5e-6, *m.OfficialPricing.InputPrice, 1e-15) + } +} + +func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}}, + }} + groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub()) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + require.Len(t, out, 1) + m := out[0].Models[0] + require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis) + require.Len(t, m.Pricing.Intervals, 2) + require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel) + require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel) + require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15) + require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) + // 官方参考不套用站内旧规则 + require.NotNil(t, m.OfficialPricing) + require.Empty(t, m.OfficialPricing.Intervals) +} + +func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) { + channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "claude-sonnet-4")} + groups := []Group{{ + ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true, + ModelPricing: []ChannelModelPricing{{Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6)}}, + }} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.InDelta(t, 1e-6, *m.Pricing.InputPrice, 1e-15, "分组价卡优先于渠道平价") + require.InDelta(t, 15e-6, *m.Pricing.OutputPrice, 1e-15, "卡未配置的项回落目录价") + require.Empty(t, m.Pricing.Intervals) +} + +func TestListGroups_ImageModelKeepsTierSynthesisWithBilling(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, + PerRequestPrice: testPtrFloat64(0.04), + }}, + }} + groups := []Group{{ + ID: 10, Name: "g", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true, + ImagePrice1K: testPtrFloat64(0.02), + }} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.Equal(t, BillingModeImage, m.Pricing.BillingMode) + require.Empty(t, m.LongContextBasis) + require.Len(t, m.Pricing.Intervals, 3) + require.InDelta(t, 0.02, *m.Pricing.Intervals[0].PerRequestPrice, 1e-12) + require.InDelta(t, 0.04, *m.Pricing.Intervals[1].PerRequestPrice, 1e-12) +} + +func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) { + // 目录查不到的模型:计费按渠道平价(未配置项 $0),广场单档展示渠道平价,官方价为空。 + channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, PlatformAnthropic, "unknown-model-xyz")} + groups := []Group{{ID: 10, Name: "g", Platform: PlatformAnthropic, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformAnthropic}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.NotNil(t, m.Pricing) + require.InDelta(t, 3e-6, *m.Pricing.InputPrice, 1e-15) + require.Empty(t, m.Pricing.Intervals) + require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价") + require.Nil(t, m.OfficialPricing) +} + +func TestListGroups_TimePricingPassthrough(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformDeepseek, Models: []string{"deepseek-chat"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.28e-6), OutputPrice: testPtrFloat64(0.42e-6), + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + }} + groups := []Group{{ID: 10, Name: "cn", Platform: PlatformDeepseek, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformDeepseek}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.NotNil(t, m.TimePricing) + require.Equal(t, "Asia/Shanghai", m.TimePricing.Timezone) + require.Len(t, m.TimePricing.Periods, 1) + require.InDelta(t, 0.5, m.TimePricing.Periods[0].Multiplier, 1e-12) + // 展示单价为标准时段价 + require.InDelta(t, 0.28e-6, *m.Pricing.InputPrice, 1e-15) +} diff --git a/backend/internal/service/models_list_response_limit.go b/backend/internal/service/models_list_response_limit.go new file mode 100644 index 0000000000..b10260fd98 --- /dev/null +++ b/backend/internal/service/models_list_response_limit.go @@ -0,0 +1,10 @@ +package service + +import "github.com/Wei-Shaw/sub2api/internal/config" + +func resolveModelsListReadLimit(cfg *config.Config) int64 { + if cfg != nil && cfg.Gateway.ModelsListReadMaxBytes > 0 { + return cfg.Gateway.ModelsListReadMaxBytes + } + return config.DefaultModelsListReadMaxBytes +} diff --git a/backend/internal/service/models_list_response_limit_test.go b/backend/internal/service/models_list_response_limit_test.go new file mode 100644 index 0000000000..d697351510 --- /dev/null +++ b/backend/internal/service/models_list_response_limit_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestResolveModelsListReadLimit(t *testing.T) { + t.Run("nil config uses default", func(t *testing.T) { + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(nil)) + }) + + t.Run("configured limit wins", func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + require.Equal(t, int64(16<<20), resolveModelsListReadLimit(cfg)) + }) + + t.Run("non-positive limit uses default", func(t *testing.T) { + cfg := &config.Config{} + require.Equal(t, config.DefaultModelsListReadMaxBytes, resolveModelsListReadLimit(cfg)) + }) +} diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index db620cc5b8..fcda6d6a05 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -114,6 +114,33 @@ func TestOpenAI429RetryDelayHonorsBoundedRetryAfter(t *testing.T) { require.Equal(t, openAIOAuth429MaxRetryDelay, openAIOAuth429SameAccountRetryDelay(http.Header{"Retry-After": []string{"90"}}, deadline)) } +func TestOpenAI429FastPath_OpenCodeGoUsageLimitUsesMessageResetDuration(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 44, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_test/go"},"metadata":{"workspace":"wrk_test","limitName":"5 hour"}}`) + + before := time.Now() + shouldDisable := svc.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusTooManyRequests, + http.Header{}, + body, + ) + after := time.Now() + + require.False(t, shouldDisable) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, account.ID, repo.lastRateLimitID) + expectedResetAfter := 4*time.Hour + 59*time.Minute + require.False(t, repo.lastRateLimitReset.Before(before.Add(expectedResetAfter-time.Second))) + require.False(t, repo.lastRateLimitReset.After(after.Add(expectedResetAfter))) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + // TestOpenAI429FastPath_SkipsSparkShadow 外审第8轮 P1:spark 影子被选中后若 /responses 返回 429, // 不得按 global x-codex-* 信号写内存运行时熔断(否则 spark 被冷却到 global reset、单影子场景无可用账号)。 func TestOpenAI429FastPath_SkipsSparkShadow(t *testing.T) { diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 2f2fef563b..2986f6abeb 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1780,6 +1780,9 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con // rechecks won't reach healthy accounts that fell outside TopK — manifesting as // "no available accounts" even though healthy ones exist. if paused, decision := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { + if decision.reason != "" { + return false, decision.reason + } reason := "quota_auto_pause" if decision.window != "" { reason += "_" + decision.window diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 3e8c241b2a..081cf30f9a 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -466,6 +466,75 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabledUsesLega require.False(t, decision.StickyPreviousHit) } +// Regression: the legacy load-batch path had two bare ErrNoAvailableAccounts +// exits that bypassed the diagnostics added for both the advanced scheduler and +// the non-batched legacy selector. This is the default path when load batching +// is enabled, so quota auto-pause could still surface as an opaque 503. +func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_LoadBatchReportsFilterReasons(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := withOpenAIQuotaAutoPauseSettings(context.Background(), OpsOpenAIAccountQuotaAutoPauseSettings{DefaultThreshold7d: 0.9}) + groupID := int64(10107) + quotaPaused := Account{ + ID: 36003, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Extra: map[string]any{ + "codex_7d_used_percent": 95.0, + "codex_7d_reset_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339), + "codex_usage_updated_at": time.Now().Add(-time.Minute).Format(time.RFC3339), + }, + } + mappingMiss := Account{ + ID: 36004, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-4o": "gpt-4o"}, + }, + } + excluded := Account{ + ID: 36005, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = true + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{quotaPaused, mappingMiss, excluded}}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + require.False(t, svc.isOpenAIAdvancedSchedulerEnabled(ctx)) + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "", + "", + "gpt-5.4-mini", + map[int64]struct{}{excluded.ID: {}}, + OpenAIUpstreamTransportAny, + false, + ) + + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Nil(t, selection) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.EqualError(t, err, "no available OpenAI accounts supporting model: gpt-5.4-mini (pool=3, filtered: excluded=1 model_not_supported=1 quota_auto_pause_7d=1)") +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_RequiredWSV2_SkipsHTTPOnlyAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() diff --git a/backend/internal/service/openai_agent_identity_compat_test.go b/backend/internal/service/openai_agent_identity_compat_test.go index a8f234f58c..2520eaaaeb 100644 --- a/backend/internal/service/openai_agent_identity_compat_test.go +++ b/backend/internal/service/openai_agent_identity_compat_test.go @@ -133,8 +133,8 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id")) require.NotEqual(t, "client-session", req.Header.Get("session_id")) require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-session"), req.Header.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "client-conversation"), req.Header.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id")) requestBody, err := io.ReadAll(req.Body) require.NoError(t, err) require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`) @@ -147,7 +147,7 @@ func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *test Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "chatgpt_account_id": "account-oauth-passthrough", + "chatgpt_account_id": "account-agent-passthrough", }, } oauthRecorder := httptest.NewRecorder() diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index c22545f5f1..ac242f050c 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -30,6 +30,9 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if s == nil || c == nil || account == nil { return nil, fmt.Errorf("service, context, and account are required") } + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } modelResult := gjson.GetBytes(body, "model") requestedModel := strings.TrimSpace(modelResult.String()) if modelResult.Type != gjson.String || requestedModel == "" { @@ -74,7 +77,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(req, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) @@ -157,7 +160,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( SetActualOpenAIUpstreamEndpoint(c, "/v1/responses") upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(req, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) @@ -272,10 +275,11 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c } apiKeyID := getAPIKeyIDFromContext(c) if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" { - isolated := isolateOpenAISessionID(apiKeyID, sessionID) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID) req.Header.Set("Session_ID", isolated) req.Header.Set("Conversation_ID", isolated) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil @@ -393,6 +397,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" { req.Header.Set("X-Codex-Turn-Metadata", turnMetadata) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) canonical := resolveCodexOutboundIdentity("") if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" { req.Header.Set("Version", version) diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index b5d37d1dd8..8521c2160e 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -59,6 +59,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { c.Request.Header.Set("User-Agent", codexCLIUserAgent) c.Request.Header.Set("Originator", "codex_cli_rs") c.Request.Header.Set("Version", "0.144.1") + c.Request.Header.Set("X-Codex-Turn-Metadata", `{"session_id":"search-session","turn_id":"search-turn"}`) upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, @@ -92,6 +93,14 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) { require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "session", "search-session"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "session_id").String(), + ) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "search-turn"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.JSONEq(t, string(body), string(upstream.lastBody)) } @@ -156,7 +165,10 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version")) - require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata")) + require.Equal(t, + scopeCodexAccountIdentityValue(account, 0, "turn", "turn-1"), + gjson.Get(upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"), "turn_id").String(), + ) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features")) require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State")) diff --git a/backend/internal/service/openai_codex_account_identity.go b/backend/internal/service/openai_codex_account_identity.go new file mode 100644 index 0000000000..d0e336ec6c --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity.go @@ -0,0 +1,275 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const codexAccountIdentityNamespaceVersion = "v1" + +const codexAccountIdentitySourceContextKey = "openai_codex_account_identity_source" + +// prepareCodexAccountIdentitySource resolves credential shadows once per selected +// attempt. The handler reuses gin.Context across failover attempts, so every entry +// point overwrites the staged source before projecting outbound identity. +func (s *OpenAIGatewayService) prepareCodexAccountIdentitySource(ctx context.Context, c *gin.Context, account *Account) (*Account, error) { + source := account + if account != nil && account.IsShadow() { + resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, err + } + source = resolved + } + if c != nil { + c.Set(codexAccountIdentitySourceContextKey, source) + } + return source, nil +} + +func codexAccountIdentitySource(c *gin.Context, fallback *Account) *Account { + if c != nil { + if staged, ok := c.Get(codexAccountIdentitySourceContextKey); ok { + if source, ok := staged.(*Account); ok && source != nil { + return source + } + } + } + return fallback +} + +// codexAccountIdentityNamespace returns a stable, credential-scoped namespace. +// Multiple local rows that use the same ChatGPT account intentionally share the +// same namespace. Setup tokens use an irreversible bearer fingerprint because +// they have no refresh lifecycle or imported account metadata. Refreshable OAuth +// otherwise falls back only to a persistent fingerprint seed: local row IDs are +// deployment-relative and must never become upstream identity. +func codexAccountIdentityNamespace(account *Account) string { + if account == nil || !account.IsOpenAIOAuthLike() { + return "" + } + if upstreamAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); upstreamAccountID != "" { + if upstreamUserID := strings.TrimSpace(account.GetCredential("chatgpt_user_id")); upstreamUserID != "" { + return "chatgpt:" + upstreamAccountID + ":user:" + upstreamUserID + } + return "chatgpt:" + upstreamAccountID + } + if seed, ok := codexFingerprintSeed(account.Extra); ok { + return "seed:" + seed + } + if account.Type == AccountTypeSetupToken { + if token := strings.TrimSpace(account.GetOpenAIAccessToken()); token != "" { + sum := sha256.Sum256([]byte("openai-setup-token:" + token)) + return fmt.Sprintf("setup-token:%x", sum[:16]) + } + } + return "" +} + +// isolateOpenAIUpstreamSessionID preserves the existing API-key isolation while +// adding the selected OAuth credential namespace. A scheduler failover therefore +// cannot send the same session/conversation identity through two upstream accounts. +func isolateOpenAIUpstreamSessionID(apiKeyID int64, account *Account, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + namespace := codexAccountIdentityNamespace(account) + if namespace == "" { + return isolateOpenAISessionID(apiKeyID, raw) + } + sum := sha256.Sum256([]byte(fmt.Sprintf("u%d:a%s:%s", apiKeyID, namespace, raw))) + return fmt.Sprintf("%x", sum[:8]) +} + +func scopeCodexAccountIdentityValue(account *Account, apiKeyID int64, kind, raw string) string { + raw = strings.TrimSpace(raw) + namespace := codexAccountIdentityNamespace(account) + if raw == "" || namespace == "" { + return raw + } + return deriveStableUUIDv4(fmt.Sprintf( + "sub2api:codex-account-identity:%s:user:%d:account:%s:kind:%s:value:%s", + codexAccountIdentityNamespaceVersion, + apiKeyID, + namespace, + kind, + raw, + )) +} + +var codexAccountIdentityFields = []struct { + name string + kind string +}{ + {name: "installation_id", kind: "installation"}, + {name: "x-codex-installation-id", kind: "installation"}, + {name: "session_id", kind: "session"}, + {name: "session-id", kind: "session"}, + {name: "thread_id", kind: "thread"}, + {name: "thread-id", kind: "thread"}, + {name: "turn_id", kind: "turn"}, + {name: "turn-id", kind: "turn"}, + {name: "window_id", kind: "window"}, + {name: "x-codex-window-id", kind: "window"}, + {name: "x-client-request-id", kind: "request"}, +} + +func applyCodexAccountIdentityFields(values map[string]any, account *Account, apiKeyID int64) bool { + if values == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + for _, field := range codexAccountIdentityFields { + raw, ok := values[field.name].(string) + if !ok || strings.TrimSpace(raw) == "" { + continue + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw) + if next != raw { + values[field.name] = next + changed = true + } + } + return changed +} + +func applyCodexAccountIdentityEmbeddedMetadata(values map[string]any, account *Account, apiKeyID int64) bool { + raw, ok := values[openAIWSTurnMetadataHeader].(string) + if !ok || strings.TrimSpace(raw) == "" { + return false + } + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err != nil || metadata == nil { + return false + } + if !applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + return false + } + rebuilt, err := json.Marshal(metadata) + if err != nil { + return false + } + values[openAIWSTurnMetadataHeader] = string(rebuilt) + return true +} + +func applyCodexAccountIdentityClientMetadataMap(requestBody map[string]any, account *Account, apiKeyID int64) bool { + if requestBody == nil || codexAccountIdentityNamespace(account) == "" { + return false + } + changed := false + clientMetadata, _ := requestBody["client_metadata"].(map[string]any) + originalBodySessionID := "" + if clientMetadata != nil { + originalBodySessionID, _ = clientMetadata["session_id"].(string) + if applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) { + changed = true + } + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + changed = true + } + } + if raw, ok := requestBody["prompt_cache_key"].(string); ok && strings.TrimSpace(raw) != "" { + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + next := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if next != raw { + requestBody["prompt_cache_key"] = next + changed = true + } + } + return changed +} + +// applyCodexAccountIdentityClientMetadataRaw scopes only the small identity +// subobjects with gjson/sjson. The passthrough hot path never unmarshals the +// potentially multi-megabyte request body. +func applyCodexAccountIdentityClientMetadataRaw(body []byte, account *Account, apiKeyID int64) ([]byte, bool, error) { + if len(body) == 0 || codexAccountIdentityNamespace(account) == "" { + return body, false, nil + } + root := gjson.ParseBytes(body) + if !root.IsObject() { + return body, false, nil + } + + next := body + changed := false + originalBodySessionID := "" + if cm := gjson.GetBytes(body, "client_metadata"); cm.IsObject() { + clientMetadata := map[string]any{} + if err := json.Unmarshal([]byte(cm.Raw), &clientMetadata); err != nil { + return body, false, fmt.Errorf("decode client_metadata for account identity: %w", err) + } + originalBodySessionID, _ = clientMetadata["session_id"].(string) + metadataChanged := applyCodexAccountIdentityFields(clientMetadata, account, apiKeyID) + if applyCodexAccountIdentityEmbeddedMetadata(clientMetadata, account, apiKeyID) { + metadataChanged = true + } + if metadataChanged { + raw, err := json.Marshal(clientMetadata) + if err != nil { + return body, false, fmt.Errorf("encode account-scoped client_metadata: %w", err) + } + var setErr error + next, setErr = sjson.SetRawBytes(next, "client_metadata", raw) + if setErr != nil { + return body, false, fmt.Errorf("splice account-scoped client_metadata: %w", setErr) + } + changed = true + } + } + if promptCacheKey := gjson.GetBytes(body, "prompt_cache_key"); promptCacheKey.Type == gjson.String && strings.TrimSpace(promptCacheKey.String()) != "" { + raw := promptCacheKey.String() + kind := "prompt-cache" + if strings.TrimSpace(originalBodySessionID) != "" && raw == originalBodySessionID { + kind = "session" + } + scoped := scopeCodexAccountIdentityValue(account, apiKeyID, kind, raw) + if scoped != raw { + rewritten, err := sjson.SetBytes(next, "prompt_cache_key", scoped) + if err != nil { + return body, false, fmt.Errorf("splice account-scoped prompt_cache_key: %w", err) + } + next = rewritten + changed = true + } + } + return next, changed, nil +} + +func applyCodexAccountIdentityHeaders(headers http.Header, account *Account, apiKeyID int64) { + if headers == nil || codexAccountIdentityNamespace(account) == "" { + return + } + for _, field := range codexAccountIdentityFields { + // Underscore session/conversation headers are rebuilt separately from the + // prompt cache key by each request builder. + if field.name == "session_id" { + continue + } + raw := strings.TrimSpace(headers.Get(field.name)) + if raw != "" { + headers.Set(field.name, scopeCodexAccountIdentityValue(account, apiKeyID, field.kind, raw)) + } + } + if raw := strings.TrimSpace(headers.Get(openAIWSTurnMetadataHeader)); raw != "" { + metadata := map[string]any{} + if err := json.Unmarshal([]byte(raw), &metadata); err == nil && metadata != nil && applyCodexAccountIdentityFields(metadata, account, apiKeyID) { + if rebuilt, err := json.Marshal(metadata); err == nil { + headers.Set(openAIWSTurnMetadataHeader, string(rebuilt)) + } + } + } +} diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go new file mode 100644 index 0000000000..ffceba2d39 --- /dev/null +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -0,0 +1,230 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type codexAccountIdentityRepoStub struct { + AccountRepository + account *Account +} + +func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) { + return s.account, nil +} + +func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`) + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + + first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + firstAgain, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77) + require.NoError(t, err) + require.True(t, changed) + second, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account19, 77) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, string(first), string(firstAgain)) + + paths := []string{ + "prompt_cache_key", + "client_metadata.x-codex-installation-id", + "client_metadata.session_id", + "client_metadata.thread_id", + "client_metadata.x-codex-window-id", + } + for _, path := range paths { + require.NotEqual(t, gjson.GetBytes(body, path).String(), gjson.GetBytes(first, path).String(), path) + require.NotEqual(t, gjson.GetBytes(first, path).String(), gjson.GetBytes(second, path).String(), path) + } + require.Equal(t, gjson.GetBytes(first, "prompt_cache_key").String(), gjson.GetBytes(first, "client_metadata.session_id").String()) + + var embeddedFirst map[string]any + var embeddedSecond map[string]any + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst)) + require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond)) + for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} { + require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field) + } +} + +func TestCodexAccountIdentityNamespaceUsesStableCredentialSource(t *testing.T) { + firstRow := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + secondRow := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}} + require.Equal(t, codexAccountIdentityNamespace(firstRow), codexAccountIdentityNamespace(secondRow)) + + firstUser := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + sameUser := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}} + secondUser := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-2"}} + require.Equal(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(sameUser)) + require.NotEqual(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(secondUser)) + + seed := "11111111-1111-4111-8111-111111111111" + seeded := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}} + require.Equal(t, "seed:"+seed, codexAccountIdentityNamespace(seeded)) + + // Local row IDs repeat across independent deployments, so they are not a + // safe fallback for upstream identity. + require.Empty(t, codexAccountIdentityNamespace(&Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) + + setupTokenA := &Account{ID: 30, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenADuplicate := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}} + setupTokenB := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-b"}} + setupNamespace := codexAccountIdentityNamespace(setupTokenA) + require.NotEmpty(t, setupNamespace) + require.NotContains(t, setupNamespace, "setup-token-a") + require.Equal(t, setupNamespace, codexAccountIdentityNamespace(setupTokenADuplicate)) + require.NotEqual(t, setupNamespace, codexAccountIdentityNamespace(setupTokenB)) +} + +func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + parentID := int64(11) + parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "team-account", + "chatgpt_user_id": "user-1", + }} + shadow := &Account{ID: 111, ParentAccountID: &parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + service := &OpenAIGatewayService{accountRepo: &codexAccountIdentityRepoStub{account: parent}} + + resolved, err := service.prepareCodexAccountIdentitySource(context.Background(), c, shadow) + require.NoError(t, err) + require.Same(t, parent, resolved) + require.Same(t, parent, codexAccountIdentitySource(c, shadow)) + + req, err := service.buildUpstreamRequest( + context.Background(), c, shadow, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id")) + + next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ + "chatgpt_account_id": "other-account", + "chatgpt_user_id": "user-2", + }} + resolved, err = service.prepareCodexAccountIdentitySource(context.Background(), c, next) + require.NoError(t, err) + require.Same(t, next, resolved) + require.Same(t, next, codexAccountIdentitySource(c, shadow)) +} + +func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Set("api_key_id", int64(77)) + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("x-client-request-id", "client-request") + + account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}} + account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}} + service := &OpenAIGatewayService{} + build := func(account *Account) http.Header { + headers, _, err := service.buildOpenAIWSHeaders( + context.Background(), c, account, "token", + OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}, + true, "", "", "client-session", "", "", + ) + require.NoError(t, err) + return headers + } + + first := build(account11) + firstAgain := build(account11) + second := build(account19) + for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} { + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), header) + require.NotEqual(t, first.Get(header), second.Get(header), header) + } + + httpRequest, err := service.buildUpstreamRequest( + context.Background(), c, account11, + []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`), + "token", true, "client-session", true, + ) + require.NoError(t, err) + require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key") +} + +func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{} + body := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`) + + build := func(accountID int64, chatgptAccountID string) http.Header { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 77}) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.0") + c.Request.Header.Set("x-codex-installation-id", "client-installation") + c.Request.Header.Set("x-codex-window-id", "client-window") + c.Request.Header.Set("session-id", "client-session") + c.Request.Header.Set("thread-id", "client-thread") + c.Request.Header.Set("x-client-request-id", "client-request") + c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","turn_id":"client-turn","window_id":"client-window"}`) + + account := &Account{ + ID: accountID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": chatgptAccountID, + }, + } + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "oauth-token", true, "client-session", true, + ) + require.NoError(t, err) + return req.Header + } + + first := build(11, "chatgpt-account-11") + firstAgain := build(11, "chatgpt-account-11") + second := build(19, "chatgpt-account-19") + + identityHeaders := []string{ + "x-codex-installation-id", + "x-codex-window-id", + "session-id", + "session_id", + "conversation_id", + "thread-id", + "x-client-request-id", + "x-codex-turn-metadata", + } + checked := 0 + for _, header := range identityHeaders { + if first.Get(header) == "" && second.Get(header) == "" { + continue + } + checked++ + require.NotEmpty(t, first.Get(header), header) + require.Equal(t, first.Get(header), firstAgain.Get(header), "same account must retain stable identity: %s", header) + require.NotEqual(t, first.Get(header), second.Get(header), "account failover must rotate upstream identity: %s", header) + } + require.GreaterOrEqual(t, checked, 5, "test must exercise the real outbound identity surface") +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 067aeb68a0..b59fc99630 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,13 +30,12 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestBodyLimit int64 = 8 << 20 - codexModelsManifestCacheBodyLimit = 1 << 20 - codexModelsManifestCacheMaxEntries = 64 - codexModelsManifestCacheTTL = 30 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute - codexModelsManifestRequestTimeout = 15 * time.Second - codexAutoModelPrefix = "codex-auto-" + codexModelsManifestCacheBodyLimit = 1 << 20 + codexModelsManifestCacheMaxEntries = 64 + codexModelsManifestCacheTTL = 30 * time.Second + codexModelsManifestCacheStaleTTL = 5 * time.Minute + codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) // FilterCodexModelIDsForGroup removes dedicated media-generation models and @@ -1607,15 +1606,21 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency) } else { - client, clientErr := httpclient.GetClient(httpclient.Options{ - ProxyURL: request.proxyURL, - Timeout: codexModelsManifestRequestTimeout, - ResponseHeaderTimeout: 10 * time.Second, - }) - if clientErr != nil { - return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr) + handled := false + if s.pluginManager != nil { + resp, handled, err = s.pluginManager.RoundTripOpenAIOAuth(reqCtx, req, request.proxyURL, request.credentialAccount) + } + if !handled { + client, clientErr := httpclient.GetClient(httpclient.Options{ + ProxyURL: request.proxyURL, + Timeout: codexModelsManifestRequestTimeout, + ResponseHeaderTimeout: 10 * time.Second, + }) + if clientErr != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr) + } + resp, err = client.Do(req) } - resp, err = client.Do(req) } if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -1644,13 +1649,20 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } - body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err), retryable: isRetryableCodexModelsManifestTransportError(err), } } + if int64(len(body)) > bodyLimit { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest response exceeds %d bytes", bodyLimit), + retryable: true, + } + } upstreamBody := body if request.useAPIKeyUpstream { body = convertOpenAIModelListToCodexManifest(body) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index d559a35437..de9ea4c417 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/json" "errors" @@ -1864,6 +1865,51 @@ func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t } } +func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) { + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + s.cfg.Gateway.ModelsListReadMaxBytes = 8 + _, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example"), + "0.144.0", + "", + ) + require.Error(t, err) + require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err)) + require.Contains(t, err.Error(), "response exceeds 8 bytes") + require.True(t, IsRetryableCodexModelsManifestError(err)) +} + +func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) { + manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}` + require.Greater(t, len(manifestBody), 8<<20) + require.Less(t, len(manifestBody), 16<<20) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, manifestBody) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{cfg: &config.Config{}} + s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20 + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "") + require.NoError(t, err) + require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact") +} + func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/openai_codex_tool_names_test.go b/backend/internal/service/openai_codex_tool_names_test.go index d6cb9eafa1..73b7164deb 100644 --- a/backend/internal/service/openai_codex_tool_names_test.go +++ b/backend/internal/service/openai_codex_tool_names_test.go @@ -91,7 +91,7 @@ func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) { require.Equal(t, codexPythonToolAlias, tool["name"]) apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`) - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(apiKeyBody), string(normalized)) diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go index db8b6b404b..16587df0fd 100644 --- a/backend/internal/service/openai_compact_body_signal_test.go +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -96,7 +96,7 @@ func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *test body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) require.NoError(t, err) require.True(t, changed) items := gjson.GetBytes(normalized, "input").Array() diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 7ccfbdeadd..b52a616f85 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1010,7 +1010,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, secondResult) require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 366e11a22f..87b1904923 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -92,7 +92,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go new file mode 100644 index 0000000000..57e1b59687 --- /dev/null +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -0,0 +1,841 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// --------------------------------------------------------------------------- +// 请求侧:service_tier 校验(fast/priority 等价、非法值拒绝、省略保持现状) +// --------------------------------------------------------------------------- + +func TestValidateOpenAIServiceTierField(t *testing.T) { + t.Parallel() + + t.Run("fast normalizes to priority", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"fast"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("priority passes through", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"priority"}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("case and whitespace insensitive", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":" FAST "}`)) + require.NoError(t, err) + require.Equal(t, "priority", norm) + }) + + t.Run("official tiers pass through", func(t *testing.T) { + for _, tier := range []string{"flex", "auto", "default", "scale"} { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`)) + require.NoError(t, err, "tier %q must be accepted", tier) + require.Equal(t, tier, norm) + } + }) + + t.Run("invalid tier rejected", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"turbo"}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "turbo", invalid.Value) + require.Contains(t, err.Error(), "invalid service_tier") + require.Contains(t, err.Error(), "fast", "allowed-value hint must mention fast") + }) + + t.Run("omitted field stays valid", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","input":"hi"}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("null value keeps omission semantics", func(t *testing.T) { + norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":null}`)) + require.NoError(t, err) + require.Empty(t, norm) + }) + + t.Run("explicit empty string rejected as invalid enum value", func(t *testing.T) { + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":""}`)) + require.Error(t, err) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + }) + + t.Run("non-string service_tier rejected", func(t *testing.T) { + // service_tier 必须为字符串;数字/布尔/对象/数组等类型同样按非法值拒绝。 + for _, raw := range []string{ + `{"model":"gpt-5.5","service_tier":123}`, + `{"model":"gpt-5.5","service_tier":true}`, + `{"model":"gpt-5.5","service_tier":{}}`, + `{"model":"gpt-5.5","service_tier":["priority"]}`, + } { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err, "raw=%s must be rejected", raw) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid), "raw=%s", raw) + require.Equal(t, "", invalid.Value, "raw=%s", raw) + require.Contains(t, err.Error(), "invalid service_tier") + } + }) + + t.Run("oversized unknown string is truncated", func(t *testing.T) { + blob := strings.Repeat("z", 4096) + _, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + blob + `"}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, strings.Repeat("z", 64)+"...", invalid.Value) + }) + + t.Run("non-string large object/array is not echoed", func(t *testing.T) { + blob := strings.Repeat("x", 4096) + payloads := []string{ + `{"model":"gpt-5.5","service_tier":{"blob":"` + blob + `"}}`, + `{"model":"gpt-5.5","service_tier":["` + blob + `"]}`, + } + for _, raw := range payloads { + _, err := ValidateOpenAIServiceTierField([]byte(raw)) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid service_tier") + require.NotContains(t, err.Error(), blob) + require.Less(t, len(err.Error()), 200) + var invalid *ErrInvalidOpenAIServiceTier + require.True(t, errors.As(err, &invalid)) + require.Equal(t, "", invalid.Value) + } + }) +} + +// --------------------------------------------------------------------------- +// 计费:gpt-5.6 系列 / gpt-5.4 按标准价 2x,gpt-5.5 按标准价 2.5x +// --------------------------------------------------------------------------- + +func TestApplyModelSpecificPricingPolicy_EnforcesOpenAIFastRatios(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + + t.Run("gpt-5.5 catalog 2x priority is corrected to 2.5x", func(t *testing.T) { + // 模拟本地 LiteLLM 目录仍携带官方旧口径(gpt-5.5 priority = 2x)。 + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + // 标准价不被改动。 + require.InDelta(t, 5e-6, got.InputPricePerToken, 1e-12) + // 原始指针不被污染。 + require.InDelta(t, 10e-6, catalog.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.4 keeps 2x", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.4", &ModelPricing{ + InputPricePerToken: 2.5e-6, + InputPricePerTokenPriority: 5e-6, + OutputPricePerToken: 15e-6, + OutputPricePerTokenPriority: 30e-6, + }) + require.InDelta(t, 5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, got.OutputPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.6 family keeps 2x", func(t *testing.T) { + for _, model := range []string{"gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-max", "gpt-5.6-sol-preview"} { + got := svc.applyModelSpecificPricingPolicy(model, &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + }) + require.InDelta(t, 10e-6, got.InputPricePerTokenPriority, 1e-12, "model %s", model) + require.InDelta(t, 60e-6, got.OutputPricePerTokenPriority, 1e-12, "model %s", model) + } + }) + + t.Run("missing priority prices are backfilled from standard", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5", &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheReadPricePerToken: 0.5e-6, + CacheCreationPricePerToken: 5e-6, + }) + require.InDelta(t, 12.5e-6, got.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, got.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 1.25e-6, got.CacheReadPricePerTokenPriority, 1e-12) + require.InDelta(t, 12.5e-6, got.CacheCreationPricePerTokenPriority, 1e-12) + }) + + t.Run("gpt-5.5-pro has no mandated fast tier", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("gpt-5.5-pro", &ModelPricing{ + InputPricePerToken: 30e-6, + InputPricePerTokenPriority: 60e-6, + OutputPricePerToken: 180e-6, + }) + require.InDelta(t, 60e-6, got.InputPricePerTokenPriority, 1e-12) + }) + + t.Run("unrelated models untouched", func(t *testing.T) { + got := svc.applyModelSpecificPricingPolicy("claude-opus-5", &ModelPricing{InputPricePerToken: 1, OutputPricePerToken: 2}) + require.InDelta(t, 1, got.InputPricePerToken, 1e-12) + require.Zero(t, got.InputPricePerTokenPriority) + }) +} + +func TestOpenAIFastBillingMultiplier_2xAnd25x(t *testing.T) { + t.Parallel() + + // 目录数据携带官方旧口径(gpt-5.5 priority=2x);修正后 fast 必须按 2.5x 计费。 + catalog := map[string]*LiteLLMModelPricing{ + "gpt-5.4": { + InputCostPerToken: 2.5e-6, + InputCostPerTokenPriority: 5e-6, + OutputCostPerToken: 15e-6, + OutputCostPerTokenPriority: 30e-6, + CacheReadInputTokenCost: 0.25e-6, + CacheReadInputTokenCostPriority: 0.5e-6, + }, + "gpt-5.5": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + "gpt-5.6-sol": { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + } + billing := NewBillingService(&config.Config{}, &PricingService{pricingData: catalog}) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000} + + standard := func(model string) *CostBreakdown { + cost, err := billing.CalculateCost(model, tokens, 1) + require.NoError(t, err) + return cost + } + fast := func(model, tier string) *CostBreakdown { + cost, err := billing.CalculateCostWithServiceTier(model, tokens, 1, tier) + require.NoError(t, err) + return cost + } + + tests := []struct { + model string + ratio float64 + }{ + {model: "gpt-5.4", ratio: 2.0}, + {model: "gpt-5.5", ratio: 2.5}, + {model: "gpt-5.6-sol", ratio: 2.0}, + {model: "gpt-5.6-terra", ratio: 2.0}, + {model: "gpt-5.6-luna", ratio: 2.0}, + } + for _, tt := range tests { + t.Run(tt.model+"/fast", func(t *testing.T) { + base := standard(tt.model) + fastCost := fast(tt.model, "fast") + require.InDelta(t, base.TotalCost*tt.ratio, fastCost.TotalCost, 1e-9, + "fast total must be %.1fx standard", tt.ratio) + }) + t.Run(tt.model+"/priority_alias", func(t *testing.T) { + fastCost := fast(tt.model, "fast") + priorityCost := fast(tt.model, "priority") + require.InDelta(t, fastCost.TotalCost, priorityCost.TotalCost, 1e-12, + "client alias fast must bill identically to priority") + require.InDelta(t, standard(tt.model).TotalCost*tt.ratio, priorityCost.TotalCost, 1e-9) + }) + t.Run(tt.model+"/no_tier_unchanged", func(t *testing.T) { + base := standard(tt.model) + noTier, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, noTier.TotalCost, 1e-12) + }) + t.Run(tt.model+"/default_equals_standard", func(t *testing.T) { + base := standard(tt.model) + defaultCost, err := billing.CalculateCostWithServiceTier(tt.model, tokens, 1, "default") + require.NoError(t, err) + require.InDelta(t, base.TotalCost, defaultCost.TotalCost, 1e-12) + require.InDelta(t, base.InputCost, defaultCost.InputCost, 1e-12) + require.InDelta(t, base.OutputCost, defaultCost.OutputCost, 1e-12) + require.InDelta(t, base.CacheReadCost, defaultCost.CacheReadCost, 1e-12) + }) + } +} + +func TestOpenAIFastBilling_FastMultiplierOverridesEnforcedRatio(t *testing.T) { + t.Parallel() + + svc := &BillingService{} + catalog := &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + pricing := svc.applyModelSpecificPricingPolicy("gpt-5.5", catalog) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12, "enforce must still write 2.5x priority prices") + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) + + multiplier := 1.7 + pricing.FastMultiplier = &multiplier + + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + standard := svc.computeTokenBreakdown(pricing, tokens, 1, "", false) + fast := svc.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + priority := svc.computeTokenBreakdown(pricing, tokens, 1, "priority", false) + + require.InDelta(t, standard.TotalCost*1.7, fast.TotalCost, 1e-9) + require.InDelta(t, fast.TotalCost, priority.TotalCost, 1e-12) + + withoutOverride := *pricing + withoutOverride.FastMultiplier = nil + enforced := svc.computeTokenBreakdown(&withoutOverride, tokens, 1, "fast", false) + require.InDelta(t, standard.TotalCost*2.5, enforced.TotalCost, 1e-9, + "without FastMultiplier the same enforced prices still bill 2.5x") +} + +// --------------------------------------------------------------------------- +// 上游 payload:fast 归一化为 priority 并确实到达上游 +// --------------------------------------------------------------------------- + +func TestForwardAsChatCompletions_ServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) // upstream 400 → 错误返回,但请求体已被 recorder 捕获 + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach upstream as priority") +} + +func TestForwardAsChatCompletions_ServiceTierPriorityPreservedUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"priority","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-chat-st2"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop"}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 2, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + } + + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.Error(t, err) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) +} + +func TestForward_ResponsesServiceTierFastNormalizedToPriorityUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String(), + "client alias fast must reach the upstream as priority") + // 计费上下文:result 携带归一化后的 tier。 + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier) +} + +func TestForward_ResponsesServiceTierOmittedStaysOmitted(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-st2"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_2","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastBody) + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "omitted service_tier must stay omitted") + require.Nil(t, result.ServiceTier) +} + +// --------------------------------------------------------------------------- +// 流式计费上下文:service_tier 需要从请求体传到 usage 计费 +// --------------------------------------------------------------------------- + +func TestForwardStreaming_ServiceTierPropagatedToResult(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-stream-st"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "priority", *result.ServiceTier, "streaming billing context must carry the normalized tier") + // /v1/responses 流是上游 SSE 原样透传:上游没回 service_tier 就不该出现; + // 网关只在计费结果里携带请求侧 tier,不往下游流里注入。 + require.Contains(t, rec.Body.String(), `"delta":"hi"`, "streamed content must reach the client") + require.NotContains(t, rec.Body.String(), `"service_tier"`, "upstream did not return service_tier, client stream must stay untouched") +} + +// --------------------------------------------------------------------------- +// 上游回显优先:请求 fast 但上游真实返回 default → 计费按标准价 +// --------------------------------------------------------------------------- + +func TestForward_ResponsesUpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 上游回显 service_tier=default(例如请求实际被降级)。 + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-echo"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","service_tier":"default","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "upstream-echoed default must override the client-requested fast tier for billing") + // 非流式响应原样透传:客户端同样看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +func TestForwardStreaming_UpstreamEchoesDefault_OverridesRequestFast(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"fast","input":"hello","stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_s1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-resp-echo-stream"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "terminal SSE event's upstream-echoed default must win for billing") + // 流式原样透传:客户端在终止事件里看到 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) +} + +func TestForwardAsChatCompletions_UpstreamEchoesDefault_BillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"service_tier":"fast","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + streamPayload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"in_progress\"}}\n\n" + + "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"it_1\",\"output_index\":0,\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_c1\",\"object\":\"response\",\"model\":\"gpt-5.5\",\"status\":\"completed\",\"service_tier\":\"default\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n" + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid-chat-echo"}}, + Body: io.NopCloser(strings.NewReader(streamPayload)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(&openAIFastPolicyRepoStub{values: map[string]string{}}, &config.Config{}), + } + account := &Account{ + ID: 21, + Name: "openai-compatible", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-compatible"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "CC bridge must bill on the upstream-echoed default, not the requested fast") + // 缓冲转回 Chat Completions:客户端响应里如实回显 default。 + require.Contains(t, rec.Body.String(), `"service_tier":"default"`) + require.NotContains(t, rec.Body.String(), `"service_tier":"priority"`) +} + +// --------------------------------------------------------------------------- +// policy filter:删除 service_tier 后不得再按原请求 Fast 计费 +// --------------------------------------------------------------------------- + +func TestForward_ServiceTierFilteredByPolicyBillsStandard(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":"hello","stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 管理员配置 priority → filter:字段在出站前被删除。 + settings := &OpenAIFastPolicySettings{Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }}} + raw, err := json.Marshal(settings) + require.NoError(t, err) + repo := &openAIFastPolicyRepoStub{values: map[string]string{SettingKeyOpenAIFastPolicySettings: string(raw)}} + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-resp-filter"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`, + )), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + settingService: NewSettingService(repo, &config.Config{}), + } + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_responses_supported": true}, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + // 出站 body 已剥离 service_tier、上游也未回显 → 无 tier → 按标准价计费。 + require.False(t, gjson.GetBytes(upstream.lastBody, "service_tier").Exists(), + "policy filter must strip service_tier from the outbound body") + require.Nil(t, result.ServiceTier, "filtered request must not bill as fast") +} + +// --------------------------------------------------------------------------- +// 上游回显观察与解析器单测 +// --------------------------------------------------------------------------- + +func TestUpstreamResponseModelObserver_ObservesServiceTier(t *testing.T) { + t.Parallel() + + observer := &upstreamResponseModelObserver{} + // 上游约束:非终止且有类型的事件(response.created)回显的是请求档位而非 + // 实际处理档位,忽略。 + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5","service_tier":"flex"}}`), "response.created") + require.Empty(t, observer.ServiceTier()) + + // terminal 声明(带 model 帧)优先。 + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + require.Equal(t, "default", observer.ServiceTier()) + + // Chat Completions 顶层 service_tier 按 untyped payload 观察(无 type 字段)。 + ccObserver := &upstreamResponseModelObserver{} + ccObserver.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","model":"gpt-5.5","service_tier":"priority","choices":[]}`), "") + require.Equal(t, "priority", ccObserver.ServiceTier()) + + // 无 model 的帧不触发 tier 观察(上游约束:tier 声明必带 model)。 + modelFree := &upstreamResponseModelObserver{} + modelFree.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"service_tier":"default"}}`), "response.completed") + require.Empty(t, modelFree.ServiceTier()) +} + +func TestResolvedOpenAIUpstreamServiceTier(t *testing.T) { + t.Parallel() + + priority := func() *string { v := "priority"; return &v }() + + t.Run("upstream echo wins over outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("no upstream echo falls back to outbound tier", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + beginUpstreamResponseModelObservation(c) + + got := resolvedOpenAIUpstreamServiceTier(c, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("upstream alias fast normalizes to priority", func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"fast"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTier(c, nil) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer keeps outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTier(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) + + t.Run("no observer and no outbound tier stays nil", func(t *testing.T) { + require.Nil(t, resolvedOpenAIUpstreamServiceTier(nil, nil)) + }) + + t.Run("local observer wins without gin context", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.5","service_tier":"default"}}`), "response.completed") + + got := resolvedOpenAIUpstreamServiceTierFromObserver(observer, priority) + require.NotNil(t, got) + require.Equal(t, "default", *got) + }) + + t.Run("nil local observer falls back to outbound tier", func(t *testing.T) { + got := resolvedOpenAIUpstreamServiceTierFromObserver(nil, priority) + require.NotNil(t, got) + require.Equal(t, "priority", *got) + }) +} diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go index e3a93bebb3..d5be3cec49 100644 --- a/backend/internal/service/openai_gateway_apikey_item_id_test.go +++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go @@ -271,7 +271,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.True(t, changed) @@ -285,7 +285,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.False(t, changedAgain) require.JSONEq(t, string(normalized), string(second)) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index bfd421bd53..b0e98e5926 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -222,7 +222,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -249,6 +249,7 @@ type ccStreamScanState struct { // emit 回调做各自的协议转换与写出。读错误按既有约定过滤 context 取消类噪声后 // 记入 Warn 日志。 func (s *OpenAIGatewayService) scanCCStream( + c *gin.Context, resp *http.Response, logPrefix string, requestID string, @@ -272,6 +273,12 @@ func (s *OpenAIGatewayService) scanCCStream( st.SawDone = true break } + // 观察上游 CC chunk 回显的 model / service_tier(计费以回显为准)。 + // CC chunk 无 type 字段,按 untyped payload 观察(上游约束:只有终止 + // 事件与无类型 body 报告实际处理档位)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI([]byte(payload), "") + } if u := extractCCStreamUsage(payload); u != nil { st.Usage = *u @@ -331,6 +338,11 @@ func (s *OpenAIGatewayService) readCCUpstreamJSONResponse( writeError(c, http.StatusBadGateway, "api_error", "Failed to parse upstream response") return nil, OpenAIUsage{}, fmt.Errorf("parse chat completions response: %w", err) } + // 观察上游 CC JSON 回显的 model / service_tier(计费以回显为准)。 + // CC JSON 无 type 字段,按 untyped payload 观察(上游约束)。 + if observer := upstreamResponseModelObserverFromContext(c); observer != nil { + observer.ObserveOpenAI(respBody, "") + } usage := OpenAIUsage{} if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok { diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity.go b/backend/internal/service/openai_gateway_cc_tool_call_identity.go new file mode 100644 index 0000000000..6b0249c9c6 --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity.go @@ -0,0 +1,103 @@ +package service + +import ( + "bytes" + "strconv" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// stripEmptyChatToolCallIdentityFromSSELine 从 CC 流式 SSE 行中剔除 +// choices[*].delta.tool_calls[*] 上的空 id / 空 function.name 字段。 +// +// DashScope/DeepSeek 等 OpenAI 兼容上游会把同一个 tool_calls[index] +// 拆成多个 delta:首个 delta 带合法 id + function.name(arguments 可能 +// 为空),后续参数 delta 带 id:"" 与 function.name:"" 只追加 arguments +// 碎片。dsh 等客户端按 `!== undefined` 合并字段,空串会被当成有效值 +// 覆盖首包合法 id/name,最终得到 {"id":"","name":"",...} 导致 +// ToolNotFoundError: unknown tool ""。这里无状态剔除空串字段(缺失 +// 即不覆盖),不补写、不记忆首包 id/name,适用于所有走 raw CC 直转 +// 路径的账号(不限定 DeepSeek)。 +// +// 只处理流式 chunk 的 delta.tool_calls;非流式 message.tool_calls 不属于 +// 本 helper 范围。 +func stripEmptyChatToolCallIdentityFromSSELine(line string) string { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + return line + } + rewritten, changed := stripEmptyChatToolCallIdentity([]byte(payload)) + if !changed { + return line + } + prefixLen := len(line) - len(payload) + if prefixLen < 0 { + return line + } + return line[:prefixLen] + string(rewritten) +} + +// stripEmptyChatToolCallIdentity 从单个 CC 流式 chunk payload 中删除 +// choices[*].delta.tool_calls[*] 上存在但为空字符串的 id 与 +// function.name 字段;arguments(即使是空串)、index、type 与其它字段 +// 一律保留,非空 id/name 不动。多 choice、多 index 都会处理。 +// +// 返回 (原始 payload, false) 当:payload 为空、不含 "tool_calls"、 +// 非法 JSON、无 choices / delta / tool_calls 数组、或没有需要删除的 +// 字段。sjson 删除失败时 fail-closed 返回原始 payload。 +func stripEmptyChatToolCallIdentity(payload []byte) ([]byte, bool) { + if len(payload) == 0 { + return payload, false + } + // 热路径快速失败:绝大多数 chunk 没有 tool_calls。 + if !bytes.Contains(payload, []byte("tool_calls")) { + return payload, false + } + if !gjson.ValidBytes(payload) { + return payload, false + } + choices := gjson.GetBytes(payload, "choices") + if !choices.Exists() || !choices.IsArray() { + return payload, false + } + updated := payload + changed := false + for ci, choice := range choices.Array() { + delta := choice.Get("delta") + if !delta.Exists() || !delta.IsObject() { + continue + } + toolCalls := delta.Get("tool_calls") + if !toolCalls.Exists() || !toolCalls.IsArray() { + continue + } + for ti, tc := range toolCalls.Array() { + if id := tc.Get("id"); id.Exists() && id.Type == gjson.String && id.Str == "" { + next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".id") + if err != nil { + return payload, false + } + updated = next + changed = true + } + if name := tc.Get("function.name"); name.Exists() && name.Type == gjson.String && name.Str == "" { + next, err := sjson.DeleteBytes(updated, "choices."+strconv.Itoa(ci)+".delta.tool_calls."+strconv.Itoa(ti)+".function.name") + if err != nil { + return payload, false + } + updated = next + changed = true + } + } + } + if !changed { + return payload, false + } + return updated, true +} diff --git a/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go new file mode 100644 index 0000000000..aa27c80637 --- /dev/null +++ b/backend/internal/service/openai_gateway_cc_tool_call_identity_test.go @@ -0,0 +1,179 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched 首包带合法 +// id/name 的 delta 必须原样保留(changed=false)。 +func TestStripEmptyChatToolCallIdentity_FirstChunkIdentityUntouched(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.False(t, changed) + require.Equal(t, string(payload), string(rewritten)) + require.Equal(t, "call_example", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.Equal(t, "web_search", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + // 首包 arguments 为空串也不应被删除。 + require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists()) + require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_FollowUpDelta 后续参数 delta 的 +// `"id":""` 与 `"function":{"name":""}` 应被删除;arguments 碎片、 +// index、type 保留。 +func TestStripEmptyChatToolCallIdentity_FollowingDelta(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, `{"query":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) + require.Equal(t, int64(0), gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.index").Int()) + require.Equal(t, "function", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.type").String()) + // 空串字段不得再出现在 payload 里。 + require.NotContains(t, string(rewritten), `"id":""`) + require.NotContains(t, string(rewritten), `"name":""`) +} + +// TestStripEmptyChatToolCallIdentity_OnlyEmptyName / _OnlyEmptyID 只删 +// 空的那一个,非空字段必须保留。 +func TestStripEmptyChatToolCallIdentity_OnlyEmptyName(t *testing.T) { + payload := []byte(`{"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"","arguments":"{}"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.Equal(t, "call_1", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_OnlyEmptyID 覆盖 `"id": ""` 带空格的 +// JSON 形式,确认 gjson/sjson 均能识别。 +func TestStripEmptyChatToolCallIdentity_OnlyEmptyID(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id" : "" , "type" : "function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.Equal(t, "get_weather", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + require.Equal(t, "{}", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept 空 arguments 不删, +// 只有 id/name 空串被剔除。 +func TestStripEmptyChatToolCallIdentity_EmptyArgumentsKept(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.True(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").Exists()) + require.Equal(t, "", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls 两个并行 index 都要 +// 处理:合法的 index 0 保留,后续参数 delta 的 index 1 剔除空 id/name。 +func TestStripEmptyChatToolCallIdentity_TwoParallelToolCalls(t *testing.T) { + payload := []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"tool_a","arguments":"{\"x\":"}},{"index":1,"id":"","type":"function","function":{"name":"","arguments":"{\"y\":"}}]}}]}`) + rewritten, changed := stripEmptyChatToolCallIdentity(payload) + require.True(t, changed) + require.Equal(t, "call_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.id").String()) + require.Equal(t, "tool_a", gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.name").String()) + require.Equal(t, `{"x":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.0.function.arguments").String()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.id").Exists()) + require.False(t, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.name").Exists()) + require.Equal(t, `{"y":`, gjson.GetBytes(rewritten, "choices.0.delta.tool_calls.1.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_Passthrough 无 tool_calls、无 choices、 +// 非数组 tool_calls、非法 JSON 一律原样返回。 +func TestStripEmptyChatToolCallIdentity_Passthrough(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {"no tool_calls", `{"choices":[{"index":0,"delta":{"content":"hi"}}]}`}, + {"no choices", `{"id":"chatcmpl_x"}`}, + {"empty choices", `{"choices":[]}`}, + {"tool_calls not array", `{"choices":[{"index":0,"delta":{"tool_calls":{"foo":1}}}]}`}, + {"invalid JSON", `{"choices":[{`}, + {"empty string", ``}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rewritten, changed := stripEmptyChatToolCallIdentity([]byte(tt.payload)) + require.False(t, changed) + require.Equal(t, tt.payload, string(rewritten)) + }) + } +} + +// TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough SSE 行级:非 +// data 行、[DONE]、空行原样;data 行保留 `data: ` 前缀。 +func TestStripEmptyChatToolCallIdentityFromSSELine_Passthrough(t *testing.T) { + tests := []struct { + name string + line string + }{ + {"done", "data: [DONE]"}, + {"non-data line", ": keep-alive"}, + {"empty line", ""}, + {"comment line", ":"}, + {"content chunk", `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.line, stripEmptyChatToolCallIdentityFromSSELine(tt.line)) + }) + } +} + +// TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix 改写后的 +// SSE 行必须保留 `data: ` 前缀。 +func TestStripEmptyChatToolCallIdentityFromSSELine_KeepsDataPrefix(t *testing.T) { + line := `data: {"id":"chatcmpl_tool","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{}"}}]}}]}` + got := stripEmptyChatToolCallIdentityFromSSELine(line) + require.True(t, strings.HasPrefix(got, "data: ")) + payload, ok := extractOpenAISSEDataLine(got) + require.True(t, ok) + require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.id").Exists()) + require.False(t, gjson.Get(payload, "choices.0.delta.tool_calls.0.function.name").Exists()) + require.Equal(t, "{}", gjson.Get(payload, "choices.0.delta.tool_calls.0.function.arguments").String()) +} + +// TestStripEmptyChatToolCallIdentity_DshClientMerge 模拟 dsh rc.2 adapter 的 +// 合并逻辑(字段存在——含空串——才覆盖):sanitize 后合并,最终 id/name 必须 +// 仍是首包合法值,arguments 为各碎片拼接。 +func TestStripEmptyChatToolCallIdentity_DshClientMerge(t *testing.T) { + lines := []string{ + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`, + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`, + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`, + } + + var mergedID, mergedName, mergedArgs string + for _, line := range lines { + sanitized := stripEmptyChatToolCallIdentityFromSSELine(line) + payload, ok := extractOpenAISSEDataLine(sanitized) + require.True(t, ok) + for _, tc := range gjson.Get(payload, "choices.0.delta.tool_calls").Array() { + if v := tc.Get("id"); v.Exists() { + mergedID = v.String() + } + if v := tc.Get("function.name"); v.Exists() { + mergedName = v.String() + } + if v := tc.Get("function.arguments"); v.Exists() { + mergedArgs += v.String() + } + } + } + + require.Equal(t, "call_example", mergedID) + require.Equal(t, "web_search", mergedName) + require.Equal(t, `{"query":"example"}`, mergedArgs) +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 51ee5a252d..69c1458fdc 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) @@ -190,7 +193,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( responsesBody = stripped } } - responsesBody, normalizedServiceTier, err := normalizeResponsesBodyServiceTier(responsesBody) + var normalizedServiceTier string + responsesBody, normalizedServiceTier, err = normalizeResponsesBodyServiceTier(responsesBody) if err != nil { return nil, fmt.Errorf("normalize service_tier in responses-shape body: %w", err) } @@ -260,6 +264,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } else if promptCacheKey != "" { reqBody["prompt_cache_key"] = promptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) responsesBody, err = json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("remarshal after codex transform: %w", err) @@ -313,7 +318,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if promptCacheKey != "" { apiKeyID := getAPIKeyIDFromContext(c) - upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))) + upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey))) } // 7. Send request @@ -321,7 +326,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -371,11 +376,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, handleErr } - // Propagate ServiceTier and ReasoningEffort to result for billing + // Propagate ServiceTier and ReasoningEffort to result for billing. + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier,policy filter 删掉字段后不再 + // 按原请求 Fast 计费。 if handleErr == nil && result != nil { - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -389,6 +396,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr @@ -475,6 +484,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) // cyber_policy 致命不可重试:不 failover,以 Chat Completions 错误格式回写(F4), @@ -548,6 +558,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: false, Duration: time.Since(startTime), } @@ -668,6 +679,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index d4000f9289..6e24fec392 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -117,7 +117,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("build upstream request: %w", err) } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 70e623d2bf..7427c6fc9c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -69,9 +69,6 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } clientStream := gjson.GetBytes(body, "stream").Bool() - // 1b. Extract service tier from the raw body before any transformation. - serviceTier := extractOpenAIServiceTierFromBody(body) - // 2. Resolve model mapping (same as ForwardAsChatCompletions) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) @@ -106,6 +103,9 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, policyErr } upstreamBody = updatedBody + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier; + // 最终值由 resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。 + serviceTier := extractOpenAIServiceTierFromBody(upstreamBody) if account.Platform == PlatformGrok { strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody) if stripErr != nil { @@ -175,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { - customUA = "sub2api-grok/1.0" + customUA = defaultGrokUpstreamUserAgent() } resp, err := s.sendCCUpstreamRequest(ctx, c, account, targetURL, upstreamBody, clientStream, token, customUA, grokCacheIdentity) if err != nil { @@ -336,6 +336,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } } line = applyOllamaCloudRawChatCompletionsSSELine(account, line) + line = stripEmptyChatToolCallIdentityFromSSELine(line) writeLine(line) if line == "" { @@ -387,8 +388,9 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -488,8 +490,9 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index a80234913a..8a83bbc7ea 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -607,6 +607,86 @@ func TestForwardAsRawChatCompletions_SilentRefusalNormalContentExempt(t *testing require.Contains(t, rec.Body.String(), "data: [DONE]") } +// TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity 端到端验证 raw +// CC 流式直转路径剔除 DashScope/DeepSeek 后续参数 delta 的空 id/name: +// 下游仍保留首包合法 id/name 与 arguments 碎片,但后续 delta 不再带 +// `"id":""` / `"name":""`,避免 dsh 等客户端用 `!== undefined` 合并时把 +// 首包合法值覆盖掉(ToolNotFoundError: unknown tool "")。 +func TestForwardAsRawChatCompletions_StripsEmptyToolCallIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"weather"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_example","type":"function","function":{"name":"web_search","arguments":""}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"query\":"}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"example\"}"}}]}}]}`, + "", + `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_identity"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + + downstream := rec.Body.String() + require.Contains(t, downstream, `"id":"call_example"`) + require.Contains(t, downstream, `"name":"web_search"`) + require.Contains(t, downstream, `{\"query\":`) + require.Contains(t, downstream, `\"example\"}`) + require.Contains(t, downstream, "data: [DONE]") + require.NotContains(t, downstream, `"id":""`) + require.NotContains(t, downstream, `"name":""`) + + // 逐条扫下游 data payload:后续参数 delta 的 tool_calls.0.id / + // function.name 必须已剔除(Exists() == false),首包合法值保留。 + followUpSeen := false + for _, line := range strings.Split(downstream, "\n") { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + continue + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + continue + } + delta := gjson.Get(payload, "choices.0.delta") + if !delta.Exists() || !delta.Get("tool_calls").Exists() { + continue + } + id := delta.Get("tool_calls.0.id") + if id.String() == "call_example" { + require.Equal(t, "web_search", delta.Get("tool_calls.0.function.name").String()) + continue + } + require.False(t, id.Exists(), "empty id must be stripped: %s", payload) + require.False(t, delta.Get("tool_calls.0.function.name").Exists(), "empty name must be stripped: %s", payload) + require.NotEmpty(t, delta.Get("tool_calls.0.function.arguments").String()) + followUpSeen = true + } + require.True(t, followUpSeen) +} + func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 2cd4519d07..1480a2edd3 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -83,7 +83,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") @@ -323,7 +323,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( if account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 511a559db5..8dfecea276 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -29,6 +29,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body @@ -78,12 +81,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = reasoningBody } } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) { - liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body) + responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) + if responsesLite { + liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) if liteErr != nil { + param := "tools" + var validationErr *openAIResponsesLiteValidationError + if errors.As(liteErr, &validationErr) { + param = validationErr.param + } setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ - "type": "invalid_request_error", "message": liteErr.Error(), "param": "tools", + "type": "invalid_request_error", "message": liteErr.Error(), "param": param, }}) return nil, liteErr } @@ -148,7 +157,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) } if account.IsOpenAIApiKey() { - if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil { + if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil { return nil, normalizeErr } else if changed { body = normalized @@ -252,6 +261,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } bodyModified := false + clientPromptCacheKey := promptCacheKey var reqBody map[string]any ensureReqBody := func() (map[string]any, error) { if requestView.HasPatches() { @@ -478,6 +488,15 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if !isCompactRequest && applyCodexClientMetadata(decoded, account) { markDecodedModified() } + if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok { + clientPromptCacheKey = currentClientPromptCacheKey + } + // Account namespace is orthogonal to fingerprint convergence: preserve + // each client's identity cardinality, but never reuse it across OAuth + // credentials after scheduler failover. + if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) { + markDecodedModified() + } stageCodexFingerprintIDs(c, nil) // 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。 // fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。 @@ -500,7 +519,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } - if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + if strings.TrimSpace(clientPromptCacheKey) != "" { + // The body now carries an account-scoped value. Keep the original here + // so the header builder derives the same namespace exactly once. + promptCacheKey = clientPromptCacheKey + } else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" { + // Fingerprint convergence may inject a default key when the client did + // not provide one; preserve that existing fallback. promptCacheKey = currentPromptCacheKey } else if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey @@ -773,6 +798,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco c, account, wsReqBody, + clientPromptCacheKey, token, wsDecision, isCodexCLI, @@ -943,7 +969,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Send request upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if headerGuard != nil && headerGuard.stopHeaderWait() { if resp != nil && resp.Body != nil { @@ -1164,6 +1190,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { @@ -1179,7 +1207,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), - ServiceTier: serviceTier, + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, @@ -1318,12 +1347,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("version", CodexCanonicalClientVersion()) } compactSession := resolveOpenAICompactSessionID(c) - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession)) } else { req.Header.Set("accept", "text/event-stream") } if promptCacheKey != "" { - isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) + isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey) req.Header.Set("session_id", isolated) if !compatMessagesBridge || clientConversationID != "" { req.Header.Set("conversation_id", isolated) @@ -1347,6 +1376,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + // 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把 + // 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。 + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。 applyStagedCodexFingerprintHeaders(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index b8ab024014..21c7342356 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -22,14 +22,13 @@ import ( const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 - // grokUpstreamUserAgent lives in grok_upstream_headers.go (shared with TLS header helpers). - grokCLIVersion = xai.CLIClientVersion - grokDefaultResponsesModel = "grok-4.5" - grokRateLimitFallbackCooldown = 2 * time.Minute - grokRateLimitRepeatCooldown = 10 * time.Minute - grokRateLimitSustainedCooldown = 30 * time.Minute - grokRateLimitMaxAdaptiveCooldown = time.Hour - grokRateLimitBackoffQuietPeriod = time.Hour + grokCLIVersion = xai.CLIClientVersion + grokDefaultResponsesModel = "grok-4.5" + grokRateLimitFallbackCooldown = 2 * time.Minute + grokRateLimitRepeatCooldown = 10 * time.Minute + grokRateLimitSustainedCooldown = 30 * time.Minute + grokRateLimitMaxAdaptiveCooldown = time.Hour + grokRateLimitBackoffQuietPeriod = time.Hour ) func (s *OpenAIGatewayService) forwardGrokResponses( @@ -110,7 +109,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( return nil, buildErr } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) @@ -1091,6 +1090,19 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { filteredTools = append(filteredTools, raw) } } + if !grokRawToolsContainType(filteredTools, "tool_search") { + for index, raw := range filteredTools { + if !gjson.GetBytes(raw, "defer_loading").Exists() { + continue + } + cleaned, deleteErr := sjson.DeleteBytes(raw, "defer_loading") + if deleteErr != nil { + return nil, deleteErr + } + filteredTools[index] = cleaned + toolsChanged = true + } + } var err error if len(filteredTools) != len(rawTools) || toolsChanged { @@ -1125,6 +1137,15 @@ func sanitizeGrokResponsesTools(body []byte) ([]byte, error) { return body, nil } +func grokRawToolsContainType(tools []json.RawMessage, want string) bool { + for _, tool := range tools { + if strings.TrimSpace(gjson.GetBytes(tool, "type").String()) == want { + return true + } + } + return false +} + func deleteGrokOrphanToolControls(body []byte) ([]byte, error) { var err error for _, field := range []string{"tool_choice", "parallel_tool_calls"} { @@ -1329,7 +1350,7 @@ func (s *OpenAIGatewayService) describeGrokComposerImage( proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return "", OpenAIUsage{}, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index f04ab5ae43..0050d10bfa 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -607,7 +607,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 27c19866c0..d1c966ac07 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -385,6 +385,16 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) } +func TestSanitizeGrokResponsesToolsRemovesDeferredFlagsWithToolSearch(t *testing.T) { + body := []byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"shell","defer_loading":true},{"type":"function","name":"apply_patch"}]}`) + + patched, err := sanitizeGrokResponsesTools(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(type=="tool_search")`).Exists()) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="shell").defer_loading`).Exists()) + require.True(t, gjson.GetBytes(patched, `tools.#(name=="apply_patch")`).Exists()) +} + func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) { t.Parallel() @@ -818,7 +828,7 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing. require.Equal(t, "https://grok.example.test/v1/responses", req.URL.String()) require.Equal(t, "Bearer api-key", req.Header.Get("Authorization")) require.Empty(t, req.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent")) } func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) { @@ -1122,7 +1132,7 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) { require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.JSONEq(t, `{"model":"grok-imagine-image-quality","prompt":"draw a cat"}`, string(upstream.lastBody)) require.Equal(t, http.StatusOK, recorder.Code) require.JSONEq(t, `{"data":[{"url":"https://images.test/cat.png"}]}`, recorder.Body.String()) @@ -1636,7 +1646,7 @@ func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { require.Equal(t, http.MethodGet, upstream.lastReq.Method) require.Equal(t, "Bearer api-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Content-Type")) require.Empty(t, upstream.lastBody) require.Equal(t, http.StatusOK, recorder.Code) @@ -1972,7 +1982,7 @@ func TestForwardGrokResponsesAPIKeyUsesXAIResponses(t *testing.T) { require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer xai-test-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "grok-4.6", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "resp_grok_api_key", result.ResponseID) require.Equal(t, 2, result.Usage.InputTokens) @@ -2249,7 +2259,7 @@ func TestForwardAsChatCompletionsForGrokAPIKeyUsesConfiguredRawEndpointWithoutOA require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) require.Equal(t, "Bearer third-party-key", upstream.lastReq.Header.Get("Authorization")) require.Empty(t, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.NotEqual(t, grokUpstreamUserAgent, upstream.lastReq.Header.Get("User-Agent")) + require.NotEqual(t, defaultGrokUpstreamUserAgent(), upstream.lastReq.Header.Get("User-Agent")) } func TestForwardAsChatCompletionsForGrokAPIKeyRejectsNonStreamingResponseWithoutUsage(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index bee1eb1cde..17c4d633d1 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -35,6 +35,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return nil, err + } // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 @@ -227,6 +230,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey } + applyCodexAccountIdentityClientMetadataMap(reqBody, codexAccountIdentitySource(c, account), apiKeyID) delete(reqBody, "prompt_cache_key") if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey) @@ -329,7 +333,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Override session_id with a deterministic UUID derived from the isolated // session key, ensuring different API keys produce different upstream sessions. if account.Platform != PlatformGrok && promptCacheKey != "" { - isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)) + isolatedSessionID := generateSessionUUID(isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), promptCacheKey)) upstreamReq.Header.Set("session_id", isolatedSessionID) if upstreamReq.Header.Get("conversation_id") != "" { upstreamReq.Header.Set("conversation_id", isolatedSessionID) @@ -375,7 +379,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, fmt.Errorf("build grok retry request: %w", err) } } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -492,9 +496,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if promptCacheKey != "" && anthropicDigestChain != "" { s.bindOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain, promptCacheKey, anthropicMatchedDigestChain) } - if responsesReq.ServiceTier != "" { - st := responsesReq.ServiceTier - result.ServiceTier = &st + // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 + // fast policy filter/force 之后)里的 tier。 + if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { + result.ServiceTier = tier } if responsesReq.Reasoning != nil && responsesReq.Reasoning.Effort != "" { re := responsesReq.Reasoning.Effort @@ -508,6 +513,8 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if handleErr == nil && account.IsShadow() && account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } return result, handleErr @@ -572,6 +579,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( observer = beginUpstreamResponseModelObservation(c) } observer.Observe(finalResponse.Model, true) + observer.ObserveServiceTier(finalResponse.ServiceTier, true) if strings.TrimSpace(finalResponse.Status) == "failed" { payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse}) @@ -636,6 +644,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: false, Duration: time.Since(startTime), } @@ -939,6 +948,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( UpstreamModel: upstreamModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 79ab1a685a..a4bc7b9f3a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -96,7 +96,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( return nil, err } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } @@ -530,13 +530,15 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult( } // claudeUsageToOpenAIUsage 把 Anthropic 格式 usage 映射到 OpenAI 网关统一的 -// 用量结构(字段一一对应)。 +// 用量结构。Anthropic 的 input_tokens 不含缓存读写,而 OpenAI 网关内部 +// 约定 InputTokens 是包含缓存明细的总输入;这里必须先合并,RecordUsage +// 才能准确拆回互斥的计费桶。 func claudeUsageToOpenAIUsage(u *ClaudeUsage) OpenAIUsage { if u == nil { return OpenAIUsage{} } return OpenAIUsage{ - InputTokens: u.InputTokens, + InputTokens: u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens, OutputTokens: u.OutputTokens, CacheCreationInputTokens: u.CacheCreationInputTokens, CacheReadInputTokens: u.CacheReadInputTokens, diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index e2929be92e..31dccb969b 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -158,7 +158,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -204,7 +204,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( } } - scan := s.scanCCStream(resp, "openai messages chat fallback", requestID, startTime, emitChunk) + scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, emitChunk) usage := scan.Usage if scan.Err != nil { @@ -218,7 +218,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -253,7 +253,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 23c283529d..b87ce9df87 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -180,6 +180,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } reqStream = gjson.GetBytes(body, "stream").Bool() + accountScopedBody, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(body, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return nil, scopeErr + } + if accountScoped { + body = accountScopedBody + } + stageCodexFingerprintIDs(c, nil) // 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。 // 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科 @@ -204,7 +212,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } } if account != nil && account.IsOpenAI() { - normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account) + responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) + normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite) if normalizeErr != nil { return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr) } @@ -365,7 +374,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } upstreamStart := time.Now() - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { // Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to @@ -500,6 +509,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } + } else if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) } if usage == nil { @@ -514,7 +525,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( UpstreamModel: upstreamPassthroughModel, UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), - ServiceTier: serviceTier, + UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), ReasoningEffort: reasoningEffort, Stream: reqStream, OpenAIWSMode: false, @@ -667,10 +679,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( clientConversationID = promptCacheKey } if clientSessionID != "" { - req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID)) + req.Header.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientSessionID)) } if clientConversationID != "" { - req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID)) + req.Header.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), clientConversationID)) } } else if isOpenAIResponsesCompactPath(c) { // 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是 @@ -687,6 +699,8 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { req.Header.Set("user-agent", CodexCanonicalUserAgent()) } + applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + // 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头, // 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置: // 会话隔离之后、终态身份收口之前)。 diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 9cca810400..9f9c7b4c6a 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -2894,3 +2894,64 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis require.InDelta(t, 0.44, cost.TotalCost, 1e-12) require.InDelta(t, 0.44, cost.ActualCost, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_ServiceTierDowngradedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + serviceTier := "priority" + usage := OpenAIUsage{InputTokens: 100, OutputTokens: 50} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_service_tier_downgraded", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: usage, + Model: "gpt-5.4", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1017}, + User: &User{ID: 2017}, + Account: &Account{ID: 3017}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "default", *usageRepo.lastLog.ServiceTier, "usage log must record the tier actually billed") + + baseCost, calcErr := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0) + require.NoError(t, calcErr) + require.InDelta(t, baseCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10, "a request served at default must not pay the priority price") +} + +func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + usage := OpenAIUsage{InputTokens: 100, OutputTokens: 50} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_service_tier_not_raised", + UpstreamResponseServiceTier: "priority", + Usage: usage, + Model: "gpt-5.4", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1018}, + User: &User{ID: 2018}, + Account: &Account{ID: 3018}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Nil(t, usageRepo.lastLog.ServiceTier) + + baseCost, calcErr := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{InputTokens: 100, OutputTokens: 50}, 1.0) + require.NoError(t, calcErr) + require.InDelta(t, baseCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 9b69914bd3..dbe220773c 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -412,7 +412,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { } normalized = next } - if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil { + if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil { return body, false, err } else if removed { normalized = next @@ -424,13 +424,15 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) { return normalized, true, nil } -func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) { +func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) { + if responsesLite { + return body, false, nil + } parallel := gjson.GetBytes(body, "parallel_tool_calls") if !parallel.Exists() { return body, false, nil } - tools := gjson.GetBytes(body, "tools") - if tools.IsArray() && len(tools.Array()) > 0 { + if openAIRequestBodyHasTools(body) { return body, false, nil } normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls") @@ -440,6 +442,22 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er return normalized, true, nil } +// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。 +func openAIRequestBodyHasTools(body []byte) bool { + if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + for _, item := range gjson.GetBytes(body, "input").Array() { + if strings.TrimSpace(item.Get("type").String()) != "additional_tools" { + continue + } + if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 { + return true + } + } + return false +} + func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) { if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False { return body, false, nil @@ -987,7 +1005,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) return normalized, true, nil } -func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) { +func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { if account == nil || !account.IsOpenAI() { return body, false, nil } @@ -1001,7 +1019,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac } } if account.IsOpenAIApiKey() { - if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil { + if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil { return body, false, err } else if normalizedParallel { normalized = next @@ -1303,6 +1321,57 @@ func normalizeOpenAIServiceTier(raw string) *string { } } +// ErrInvalidOpenAIServiceTier indicates a request carried a service_tier value +// that is not a known OpenAI tier. HTTP handlers translate it into a 400 +// invalid_request_error so malformed values are rejected up front instead of +// being silently stripped (which would mask the user's intent to use fast +// mode). +type ErrInvalidOpenAIServiceTier struct { + Value string +} + +func (e *ErrInvalidOpenAIServiceTier) Error() string { + return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale", e.Value) +} + +const invalidOpenAIServiceTierValueMaxLen = 64 + +func boundInvalidOpenAIServiceTierValue(raw string) string { + if len(raw) <= invalidOpenAIServiceTierValueMaxLen { + return raw + } + return raw[:invalidOpenAIServiceTierValueMaxLen] + "..." +} + +// ValidateOpenAIServiceTierField validates the service_tier field of a raw +// OpenAI-compatible request body (/v1/responses and /v1/chat/completions). +// +// - absent / null → valid, returns "" (field omitted keeps current behavior) +// - "fast" → normalized to "priority" (the two are equivalent; the canonical +// value is what reaches the OpenAI upstream) +// - "priority" / "flex" / "auto" / "default" / "scale" → valid, returned as-is +// - an explicitly present non-string value, an empty string, or any other +// unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400), +// matching OpenAI's enum validation semantics +func ValidateOpenAIServiceTierField(body []byte) (string, error) { + tierResult := gjson.GetBytes(body, "service_tier") + if !tierResult.Exists() || tierResult.Type == gjson.Null { + return "", nil + } + if tierResult.Type != gjson.String { + return "", &ErrInvalidOpenAIServiceTier{Value: ""} + } + raw := strings.TrimSpace(tierResult.String()) + if raw == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: raw} + } + norm := normalizedOpenAIServiceTierValue(raw) + if norm == "" { + return "", &ErrInvalidOpenAIServiceTier{Value: boundInvalidOpenAIServiceTierValue(raw)} + } + return norm, nil +} + // OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast // policy (action=block). Mirrors BetaBlockedError on the Claude side. type OpenAIFastBlockedError struct { 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 6ff334933c..8b5fbc8288 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -259,13 +259,13 @@ func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedCont func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`) - normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false) require.NoError(t, err) require.False(t, changed) require.Equal(t, string(withTools), string(normalized)) withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`) - normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false) require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) @@ -330,3 +330,26 @@ func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { }) } } + +// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 +func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { + liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) + normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) + + // 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。 + emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) + + // Lite 请求即使没有工具,也必须保留已经固定的 false。 + toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`) + normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type) +} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 0b26a580cf..a60e37feb5 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "sort" "strconv" "strings" "sync/atomic" @@ -336,6 +337,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. needModelReplace := originalModel != mappedModel streamOutputAccumulator := apicompat.NewBufferedResponseAccumulator() + streamDoneItems := newResponsesStreamOutputItems() streamImageOutputs := make([]json.RawMessage, 0, 1) streamSeenImages := make(map[string]struct{}) searchCounter := 0 @@ -614,13 +616,14 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if imageOutput, ok := extractImageGenerationOutputFromSSEData(dataBytes, streamSeenImages); ok { streamImageOutputs = append(streamImageOutputs, imageOutput) } + streamDoneItems.Observe(dataBytes) if responsesStreamEventMayContributeToOutput(eventType) { var streamEvent apicompat.ResponsesStreamEvent if err := json.Unmarshal(dataBytes, &streamEvent); err == nil { streamOutputAccumulator.ProcessEvent(&streamEvent) } } - if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamImageOutputs); normalized { + if normalizedData, normalized := normalizeResponsesStreamingTerminalOutput(dataBytes, streamOutputAccumulator, streamDoneItems, streamImageOutputs); normalized { dataBytes = normalizedData data = string(normalizedData) line = "data: " + data @@ -1989,7 +1992,75 @@ func normalizeCompletedImageGenerationStatus(data []byte) ([]byte, bool) { } } -func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, imageOutputs []json.RawMessage) ([]byte, bool) { +// responsesStreamOutputItems remembers the raw item carried by each +// response.output_item.done event, keyed by output_index. +// +// reconstructResponseOutputFromSSE already prefers the raw done items over +// delta accumulation when it rebuilds a buffered response, because the +// accumulator models only "one reasoning, one message, N function calls" and +// therefore cannot preserve item identity, per-item status/phase, ordering, or +// item types it does not know about. The streaming path had no equivalent +// because it never sees the whole body at once; this collector gives it one. +type responsesStreamOutputItems struct { + items map[int]json.RawMessage +} + +func newResponsesStreamOutputItems() *responsesStreamOutputItems { + return &responsesStreamOutputItems{items: make(map[int]json.RawMessage)} +} + +// Observe records the item of a response.output_item.done event verbatim. The +// raw JSON is kept byte for byte so vendor extensions and future fields survive +// the rebuild. +func (r *responsesStreamOutputItems) Observe(data []byte) { + if r == nil || len(data) == 0 || !gjson.ValidBytes(data) { + return + } + if strings.TrimSpace(gjson.GetBytes(data, "type").String()) != "response.output_item.done" { + return + } + item := gjson.GetBytes(data, "item") + if !item.Exists() || !item.IsObject() { + return + } + index := int(gjson.GetBytes(data, "output_index").Int()) + r.items[index] = json.RawMessage(append([]byte(nil), item.Raw...)) +} + +func (r *responsesStreamOutputItems) HasItems() bool { + return r != nil && len(r.items) > 0 +} + +// Count reports how many distinct output items the stream reported as done. +func (r *responsesStreamOutputItems) Count() int { + if r == nil { + return 0 + } + return len(r.items) +} + +// BuildOutput returns the remembered items ordered by output_index. +func (r *responsesStreamOutputItems) BuildOutput() ([]byte, bool) { + if !r.HasItems() { + return nil, false + } + indexes := make([]int, 0, len(r.items)) + for index := range r.items { + indexes = append(indexes, index) + } + sort.Ints(indexes) + ordered := make([]json.RawMessage, 0, len(indexes)) + for _, index := range indexes { + ordered = append(ordered, r.items[index]) + } + encoded, err := json.Marshal(ordered) + if err != nil { + return nil, false + } + return encoded, true +} + +func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.BufferedResponseAccumulator, doneItems *responsesStreamOutputItems, imageOutputs []json.RawMessage) ([]byte, bool) { eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) switch eventType { case "response.completed", "response.done", "response.incomplete", "response.cancelled", "response.canceled": @@ -1998,15 +2069,28 @@ func normalizeResponsesStreamingTerminalOutput(data []byte, acc *apicompat.Buffe } output := gjson.GetBytes(data, "response.output") - hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 + hasAccumulatedOutput := (acc != nil && acc.HasContent()) || len(imageOutputs) > 0 || doneItems.HasItems() if output.Exists() && output.IsArray() { - if len(output.Array()) > 0 || !hasAccumulatedOutput { + terminalCount := len(output.Array()) + // A terminal output carrying at least as many items as the stream + // reported is left untouched. Carrying fewer means the terminal + // dropped items the stream already reported as done, and those + // reported items are the authoritative record of the turn. + if terminalCount > 0 && terminalCount >= doneItems.Count() { + return data, false + } + if terminalCount == 0 && !hasAccumulatedOutput { return data, false } } outputJSON := []byte("[]") - if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { + // Same precedence as reconstructResponseOutputFromSSE: the items the stream + // actually reported win over anything rebuilt from deltas. Image generation + // items arrive as done events too, so imageOutputs would duplicate them here. + if reconstructed, ok := doneItems.BuildOutput(); ok { + outputJSON = reconstructed + } else if reconstructed, ok := buildResponsesOutputJSON(acc, imageOutputs); ok { outputJSON = reconstructed } updated, err := sjson.SetRawBytes(data, "response.output", outputJSON) diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 88cf83310a..423cba3459 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -122,7 +122,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( return nil, fmt.Errorf("build upstream request: %w", err) } - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 4fab9cdb62..1b4a00b09e 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -39,7 +39,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } clientStream := responsesReq.Stream - serviceTier := extractOpenAIServiceTierFromBody(body) // custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为 // custom_tool_call 项,先记下名字集合;tool_search 工具同理,回程还原为 // tool_search_call 项;namespace 子工具(如 MCP 工具)摊平转发,回程按映射还原 @@ -50,6 +49,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( return nil, fmt.Errorf("resolve responses tools: %w", err) } customTools := apicompat.CustomToolNames(effectiveTools) + functionTools := apicompat.FunctionToolNames(effectiveTools) toolSearch := apicompat.HasToolSearchTool(effectiveTools) namespaceTools := apicompat.NamespaceToolNames(effectiveTools) @@ -87,9 +87,10 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } - if serviceTier == nil { - serviceTier = extractOpenAIServiceTierFromBody(chatBody) - } + // 计费兜底 tier = 最终出站 body(policy filter/force 后)里的 tier;最终值由 + // resolvedOpenAIUpstreamServiceTier 决定(上游回显优先)。filter 删掉字段后 + // 这里取到 nil,不再按原请求 Fast 计费。 + serviceTier := extractOpenAIServiceTierFromBody(chatBody) logger.L().Debug("openai responses: forwarding via raw chat completions", zap.Int64("account_id", account.ID), @@ -120,9 +121,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -130,6 +131,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -143,7 +145,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, functionTools, toolSearch, namespaceTools) s.cacheReasoningItemsFromOutput(responsesResp.Output) if s.responseHeaderFilter != nil { @@ -158,7 +160,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: false, Duration: time.Since(startTime), }, nil @@ -169,6 +171,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -182,6 +185,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools + state.FunctionTools = functionTools state.ToolSearchDeclared = toolSearch state.NamespaceTools = namespaceTools clientDisconnected := false @@ -212,7 +216,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c.Writer.Flush() } - scan := s.scanCCStream(resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { + scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state) s.cacheReasoningItemsFromEvents(events) writeEvents(events) @@ -226,7 +230,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -240,7 +244,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, @@ -270,7 +274,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( BillingModel: billingModel, UpstreamModel: upstreamModel, ReasoningEffort: reasoningEffort, - ServiceTier: serviceTier, + ServiceTier: resolvedOpenAIUpstreamServiceTier(c, serviceTier), Stream: true, Duration: time.Since(startTime), FirstTokenMs: scan.FirstTokenMs, diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 42e1c3fac8..2a4a4f3d09 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -360,24 +360,45 @@ func openAICompactSupportTier(account *Account) int { // 注意:对 spark 影子账号,调用方还须额外调用 parentHealthyForShadow(account, lookup) // 检查母账号凭据可用性;该检查未内置于本函数,以避免注入 DB 依赖。 func isOpenAICompatibleAccountEligibleForRequest(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { - if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) { - return false + return openAICompatibleAccountEligibilityFailureReason(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == "" +} + +// openAICompatibleAccountEligibilityFailureReason mirrors the legacy boolean +// eligibility check while naming its first veto point. Load-batch selection uses +// the reason only for server-side no-account diagnostics; the admission behavior +// remains unchanged. +func openAICompatibleAccountEligibilityFailureReason(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string { + if reason := openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability); reason != "" { + return reason } // 分组利润控制:legacy 引擎的粘性/候选循环与 DB recheck 共用 // 本判定,任何 fallback 都不能把利润不合格账号重新放回候选。 - if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { - return false + if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed { + return reason } - return true + return "" } // isOpenAICompatibleAccountEligibleForRequestBeforeProfit applies every // ordinary scheduling gate. Legacy selection uses it before classifying the // profit veto so earlier failures retain their actual reason. func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool { + return openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx, account, platform, requestedModel, requireCompact, requiredCapability) == "" +} + +func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Context, account *Account, platform string, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) string { platform = NormalizeOpenAICompatiblePlatform(platform) - if account == nil || account.Platform != platform || !account.IsOpenAICompatible() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) { - return false + if account == nil { + return "account_nil" + } + if account.Platform != platform || !account.IsOpenAICompatible() { + return "platform_mismatch" + } + if !account.IsSchedulableForModelWithContext(ctx, requestedModel) { + if account.IsSchedulable() { + return "model_rate_limited" + } + return "not_schedulable" } if account.IsOpenAI() { if paused, reason := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { @@ -389,7 +410,10 @@ func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context "threshold", reason.threshold, "utilization", reason.utilization, ) - return false + if reason.window != "" { + return "quota_auto_pause_" + reason.window + } + return "quota_auto_pause" } } if account.IsGrok() { @@ -400,29 +424,33 @@ func isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx context.Context "threshold", reason.threshold, "utilization", reason.utilization, ) - return false + if reason.window != "" { + return "quota_auto_pause_" + reason.window + } + return "quota_auto_pause" } } if requestedModel != "" && !account.IsModelSupported(requestedModel) { - return false + return "model_not_supported" } if !account.SupportsOpenAIEndpointCapability(requiredCapability) { if account.IsGrok() && requiredCapability == OpenAIEndpointCapabilityGrokMediaGeneration { _, reason := account.GrokMediaGenerationEligibility() slog.Debug("grok_media_account_ineligible", "account_id", account.ID, "reason", reason) } - return false + return "capability_mismatch" } if requireCompact && openAICompactSupportTier(account) == 0 { - return false + return "compact_unsupported" } - return true + return "" } type openAIQuotaAutoPauseDecision struct { window string threshold float64 utilization float64 + reason string } func shouldAutoPauseGrokAccountByQuota(account *Account) (bool, openAIQuotaAutoPauseDecision) { @@ -493,6 +521,38 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) if account == nil || !account.IsOpenAI() { return false, openAIQuotaAutoPauseDecision{} } + // 自动用卡有独立阈值:达到消费阈值时必须先退出调度;仅达到普通暂停阈值时, + // 只有新鲜状态明确存在可用卡才继续放行到消费阈值。 + if config := ResolveOpenAIAutoResetCreditConfig(account); config.Enabled { + now := time.Now() + utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + if has5h && utilization5h >= config.Threshold5h { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"} + } + if has7d && utilization7d >= config.Threshold7d { + notifyOpenAIAutoReset(account.ID) + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"} + } + + disabled5h := resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") + disabled7d := resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account) + pauseReached5h := !disabled5h && pause5h > 0 && has5h && utilization5h >= pause5h + pauseReached7d := !disabled7d && pause7d > 0 && has7d && utilization7d >= pause7d + if pauseReached5h || pauseReached7d { + state := openAIAutoResetStateFromExtra(account.Extra) + if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) { + return false, openAIQuotaAutoPauseDecision{} + } + notifyOpenAIAutoReset(account.ID) + if pauseReached5h { + return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"} + } + return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: pause7d, utilization: utilization7d, reason: "quota_auto_reset_credit_check_7d"} + } + } // Per-account explicit-disable flags must take precedence over the global default. // Without these, leaving the account threshold blank means "use global default", // so an admin has no way to exempt a single account from auto-pause once a global @@ -1101,7 +1161,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return nil, err } if len(accounts) == 0 { - return nil, ErrNoAvailableAccounts + return nil, noAvailableOpenAISelectionError(requestedModel, false, openAISelectionFilterStats{}.summary("")) } isExcluded := func(accountID int64) bool { @@ -1176,25 +1236,31 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex return a } baseCandidateCount := 0 + filterStats := openAISelectionFilterStats{pool: len(accounts)} candidates := make([]*Account, 0, len(accounts)) for i := range accounts { acc := &accounts[i] if isExcluded(acc.ID) { + filterStats.exclude("excluded") continue } // Scheduler snapshots can be temporarily stale (bucket rebuild is throttled); // re-check schedulability here so recently rate-limited/overloaded accounts // are not selected again before the bucket is rebuilt. - if !isOpenAICompatibleAccountEligibleForRequest(ctx, acc, platform, requestedModel, false, requiredCapability) { + if reason := openAICompatibleAccountEligibilityFailureReason(ctx, acc, platform, requestedModel, false, requiredCapability); reason != "" { + filterStats.exclude(reason) continue } if !parentHealthyForShadow(acc, parentLookupL2) { + filterStats.exclude("shadow_parent_unhealthy") continue } if s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel) { + filterStats.exclude("runtime_blocked") continue } if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) { + filterStats.exclude("channel_upstream_restricted") continue } baseCandidateCount++ @@ -1202,7 +1268,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } if len(candidates) == 0 { - return nil, ErrNoAvailableAccounts + return nil, noAvailableOpenAISelectionError(requestedModel, false, filterStats.summary("")) } rateOrder := openAILegacyUpstreamRateOrder{} if preferLowUpstreamRate { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 742cd732e2..c4d085b024 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -247,11 +247,14 @@ type OpenAIForwardResult struct { // response before any client-facing rewrite or protocol conversion. UpstreamResponseModel string UpstreamResponseModelConflict bool + // UpstreamResponseServiceTier is the tier the upstream reports having used + // (response service_tier: "priority" / "default" / "flex" / ...); "" when not declared. + UpstreamResponseServiceTier string // UpstreamEndpoint is the actual upstream API path used for this request. // It avoids guessing when one downstream protocol can use multiple upstream endpoints. UpstreamEndpoint string - // ServiceTier records the OpenAI Responses API service tier, e.g. "priority" / "flex". - // Nil means the request did not specify a recognized tier. + // ServiceTier 优先取上游实际响应回显的 tier;缺失时回退到最终出站 body 的 + // tier。nil 表示两者都无识别 tier。 ServiceTier *string // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. // Stored for usage records display; nil means not provided / not applicable. @@ -420,6 +423,7 @@ type OpenAIGatewayService struct { billingCacheService *BillingCacheService userGroupRateResolver *userGroupRateResolver httpUpstream HTTPUpstream + pluginManager *PluginManager deferredService *DeferredService openAITokenProvider *OpenAITokenProvider grokTokenProvider *GrokTokenProvider diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 19f3fc6800..7489456bb8 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -146,6 +146,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if !isGrokVideoUsageResult(result, nil) { ApplyOpenAIImageBillingResolution(result) } + logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(result)) // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。 // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。 @@ -1074,7 +1075,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc go func() { updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) + if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err == nil { + notifyOpenAIAutoReset(accountID) + } }() } diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 08f94fca67..c661aade36 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -34,12 +34,13 @@ const ( openAIImagesGenerationsURL = "https://api.openai.com/v1/images/generations" openAIImagesEditsURL = "https://api.openai.com/v1/images/edits" - openAIChatGPTStartURL = "https://chatgpt.com/" - openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" - openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download - openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part - openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIChatGPTStartURL = "https://chatgpt.com/" + openAIChatGPTFilesURL = "https://chatgpt.com/backend-api/files" + openAIImageBackendUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + openAIImageMaxDownloadBytes = 20 << 20 // 20MB per image download + openAIImageMaxUploadPartSize = 20 << 20 // 20MB per multipart upload part + openAIImagesResponsesMainModel = "gpt-5.4-mini" + openAIImagesVerbatimPromptInstructions = "When invoking the image_generation tool, use the user's image prompt verbatim. Do not rewrite, expand, summarize, embellish, translate, normalize punctuation, or add or remove visual details or constraints. Preserve the original language, wording, capitalization, quotes, and punctuation exactly." ) type OpenAIImagesCapability string @@ -624,7 +625,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( proxyURL = account.Proxy.URL() } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 7e32dde8d4..9374a6f7c7 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -356,6 +356,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel) + req, _ = sjson.SetBytes(req, "instructions", openAIImagesVerbatimPromptInstructions) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) input, _ = sjson.SetBytes(input, "0.content.0.text", prompt) @@ -1787,7 +1788,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( proxyURL = account.Proxy.URL() } upstreamStart := time.Now() - resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds()) if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 6761c55131..f06c213f46 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -2011,6 +2011,21 @@ func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) { require.Equal(t, "edit", gjson.GetBytes(body, "tools.0.action").String()) } +func TestBuildOpenAIImagesResponsesRequest_RequiresVerbatimUserPrompt(t *testing.T) { + prompt := "画一个蓝色马克杯,杯身只写“SkelOT”,保持大小写;白色背景,不要增加其他文字。" + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: prompt, + N: 1, + } + + body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2") + require.NoError(t, err) + require.Equal(t, openAIImagesVerbatimPromptInstructions, gjson.GetBytes(body, "instructions").String()) + require.Equal(t, prompt, gjson.GetBytes(body, "input.0.content.0.text").String()) +} + func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testing.T) { body := []byte( "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" + diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 0d9f105549..68d3bb725e 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -304,7 +304,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall( upstreamReq.Header.Set(liveAttestationHeader, attestation) applyLiveUpstreamIdentityHeaders(upstreamReq.Header) - resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency) + resp, err := s.doOpenAIUpstream(upstreamReq, resolveAccountProxyURL(account), account) if err != nil { logLiveCreateStageFailure(ctx, account.ID, "upstream_transport", err) return nil, err diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index ac5cb87c71..f86ecc6927 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -55,7 +55,7 @@ func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) { body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) - oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}) + oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String()) @@ -63,7 +63,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthField require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field) } - apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(body), string(apiKeyBody)) @@ -81,7 +81,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI if oauth { accountType = AccountTypeOAuth } - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String()) @@ -105,7 +105,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseRepl normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - }) + }, false) require.NoError(t, err) require.True(t, changed) @@ -141,13 +141,13 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) { body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`) for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) } - apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) + apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false) require.NoError(t, err) require.False(t, changed) require.JSONEq(t, string(body), string(apiKeyBody)) @@ -156,7 +156,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccount func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) { body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`) for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { - normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}) + normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go new file mode 100644 index 0000000000..82b8d359ee --- /dev/null +++ b/backend/internal/service/openai_plugin_transport.go @@ -0,0 +1,45 @@ +package service + +import "net/http" + +func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) { + s.pluginManager = manager +} + +// doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。 +// 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。 +func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) { + if s.pluginManager != nil { + response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) + if handled { + return response, err + } + } + return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency) +} + +// doOpenAIAccountTestUpstream 让 OpenAI OAuth 账号测试与真实转发使用同一插件路径。 +// API Key 和未命中插件的账号保持各自原有的 HTTPUpstream 行为。 +func (s *AccountTestService) doOpenAIAccountTestUpstream( + request *http.Request, + proxyURL string, + account *Account, + useTLSFallback bool, +) (*http.Response, error) { + if s.pluginManager != nil { + response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) + if handled { + return response, err + } + } + if useTLSFallback { + return s.httpUpstream.DoWithTLS( + request, + proxyURL, + account.ID, + account.Concurrency, + s.tlsFPProfileService.ResolveTLSProfile(account), + ) + } + return s.httpUpstream.Do(request, proxyURL, account.ID, account.Concurrency) +} diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go new file mode 100644 index 0000000000..aafc5991d7 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset.go @@ -0,0 +1,809 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "sort" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/google/uuid" +) + +const ( + openAIAutoResetScanInterval = time.Minute + openAIAutoResetSnapshotTTL = openAIProbeCacheTTL + openAIAutoResetBatchSize = 100 + openAIAutoResetWorkerCount = 4 + openAIAutoResetQueueCapacity = 1024 + openAIAutoResetAttemptTTL = 8 * 24 * time.Hour + openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit" +) + +const ( + OpenAIAutoResetStatusChecking = "checking" + OpenAIAutoResetStatusAvailable = "available" + OpenAIAutoResetStatusResetting = "resetting" + OpenAIAutoResetStatusSuccess = "success" + OpenAIAutoResetStatusNoCredit = "no_credit" + OpenAIAutoResetStatusFailed = "failed" +) + +// OpenAIAutoResetCreditState 是可返回管理端的脱敏运行态。Attempt* 仅保存不可逆 +// 指纹,用于重启后拒绝切换到另一张卡;不会保存卡 ID 或兑换 ID。 +type OpenAIAutoResetCreditState struct { + Status string `json:"status"` + TriggerWindow string `json:"trigger_window,omitempty"` + AvailableCount int `json:"available_count"` + CheckedAt string `json:"checked_at,omitempty"` + LastResultAt string `json:"last_result_at,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + AttemptCycleHash string `json:"attempt_cycle_hash,omitempty"` + AttemptCreditHash string `json:"attempt_credit_hash,omitempty"` +} + +type openAIAutoResetQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error + ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) +} + +type openAIAutoResetContextKey struct{} + +func withOpenAIAutoResetContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, openAIAutoResetContextKey{}, true) +} + +func isOpenAIAutoResetContext(ctx context.Context) bool { + if ctx == nil { + return false + } + value, _ := ctx.Value(openAIAutoResetContextKey{}).(bool) + return value +} + +type openAIAutoResetRecovery interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaAutoResetService 通过小型去重队列承接实时信号,并用分钟扫描补偿 +// 重启、漏事件和多实例读取;真正消费仍由 PostgreSQL 幂等记录串行化。 +type OpenAIQuotaAutoResetService struct { + accountRepo AccountRepository + quota openAIAutoResetQuota + recoverer openAIAutoResetRecovery + idempotency *IdempotencyCoordinator + audit *AuditLogService + settings *SettingService + leaderLock LeaderLockCache + + ctx context.Context + cancel context.CancelFunc + queue chan int64 + pending sync.Map + owner string + start sync.Once + stop sync.Once + wg sync.WaitGroup +} + +func NewOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quota openAIAutoResetQuota, + recoverer openAIAutoResetRecovery, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settings *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenAIQuotaAutoResetService{ + accountRepo: accountRepo, + quota: quota, + recoverer: recoverer, + idempotency: idempotency, + audit: audit, + settings: settings, + leaderLock: leaderLock, + ctx: ctx, + cancel: cancel, + queue: make(chan int64, openAIAutoResetQueueCapacity), + owner: uuid.NewString(), + } +} + +func (s *OpenAIQuotaAutoResetService) Start() { + if s == nil || s.accountRepo == nil || s.quota == nil || s.idempotency == nil { + return + } + s.start.Do(func() { + setOpenAIAutoResetNotifier(s) + for range openAIAutoResetWorkerCount { + s.wg.Add(1) + go s.runWorker() + } + s.wg.Add(1) + go s.runScanner() + }) +} + +func (s *OpenAIQuotaAutoResetService) Stop() { + if s == nil { + return + } + s.stop.Do(func() { + clearOpenAIAutoResetNotifier(s) + s.cancel() + s.wg.Wait() + }) +} + +// Notify 是请求热路径的非阻塞入口。同一账号尚在队列时只保留一个任务;队列 +// 满时丢弃本次信号,分钟扫描仍会补偿,因此不会反向拖慢网关请求。 +func (s *OpenAIQuotaAutoResetService) Notify(accountID int64) { + if s == nil || accountID <= 0 { + return + } + if _, loaded := s.pending.LoadOrStore(accountID, struct{}{}); loaded { + return + } + select { + case <-s.ctx.Done(): + s.pending.Delete(accountID) + case s.queue <- accountID: + default: + s.pending.Delete(accountID) + slog.Warn("openai_auto_reset_queue_full", "account_id", accountID) + } +} + +func (s *OpenAIQuotaAutoResetService) runWorker() { + defer s.wg.Done() + for { + select { + case <-s.ctx.Done(): + return + case accountID := <-s.queue: + ctx, cancel := context.WithTimeout(s.ctx, 50*time.Second) + if err := s.evaluateAccount(ctx, accountID); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("openai_auto_reset_evaluate_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + } + cancel() + s.pending.Delete(accountID) + } + } +} + +func (s *OpenAIQuotaAutoResetService) runScanner() { + defer s.wg.Done() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case <-s.ctx.Done(): + return + case <-timer.C: + s.scanEnabledAccounts(s.ctx) + } + ticker := time.NewTicker(openAIAutoResetScanInterval) + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + s.scanEnabledAccounts(s.ctx) + } + } +} + +func (s *OpenAIQuotaAutoResetService) scanEnabledAccounts(ctx context.Context) { + release, scan := s.tryAcquireScanLock(ctx) + if !scan { + return + } + if release != nil { + defer release() + } + for page := 1; ; page++ { + accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{ + Page: page, PageSize: openAIAutoResetBatchSize, + }, PlatformOpenAI, AccountTypeOAuth, StatusActive, "", 0, "") + if err != nil { + slog.Warn("openai_auto_reset_scan_failed", "page", page, "error", err) + return + } + for i := range accounts { + account := &accounts[i] + if account.Schedulable && ResolveOpenAIAutoResetCreditConfig(account).Enabled { + s.Notify(account.ID) + } + } + if len(accounts) < openAIAutoResetBatchSize || pageInfo == nil || page >= pageInfo.Pages { + return + } + } +} + +// Redis 锁异常时允许重复扫描,避免协调设施故障导致所有实例同时停止补偿; +// 消费唯一性由数据库幂等记录负责,扫描锁只用于削减重复查询。 +func (s *OpenAIQuotaAutoResetService) tryAcquireScanLock(ctx context.Context) (func(), bool) { + if s.leaderLock == nil { + return func() {}, true + } + ok, err := s.leaderLock.TryAcquireLeaderLock(ctx, openAIAutoResetLeaderLockKey, s.owner, 55*time.Second) + if err != nil { + slog.Warn("openai_auto_reset_leader_lock_unavailable", "error", err) + return func() {}, true + } + if !ok { + return nil, false + } + return func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = s.leaderLock.ReleaseLeaderLock(releaseCtx, openAIAutoResetLeaderLockKey, s.owner) + }, true +} + +type openAIAutoResetAssessment struct { + triggerWindow string + resetReached bool + pauseReached bool + utilization5h float64 + utilization7d float64 + threshold5h float64 + threshold7d float64 +} + +func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accountID int64) error { + ctx = withOpenAIAutoResetContext(ctx) + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + if account.IsShadow() { + if account.ParentAccountID != nil { + s.Notify(*account.ParentAccountID) + } + return nil + } + config := ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled || !account.IsActive() || !account.Schedulable { + return nil + } + + now := time.Now() + assessment := s.assessExtra(account, config, now) + state := openAIAutoResetStateFromExtra(account.Extra) + needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached + if assessment.pauseReached && !assessment.resetReached { + needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now) + } + if !needsQuery { + if !assessment.pauseReached && state != nil && state.TriggerWindow != "" { + state.TriggerWindow = "" + state.ErrorCode = "" + state.CheckedAt = now.UTC().Format(time.RFC3339) + if state.AvailableCount > 0 { + state.Status = OpenAIAutoResetStatusAvailable + } else { + state.Status = OpenAIAutoResetStatusNoCredit + } + return s.persistState(ctx, accountID, state) + } + return nil + } + + checking := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusChecking, + TriggerWindow: assessment.triggerWindow, + AvailableCount: stateAvailableCount(state), + CheckedAt: now.UTC().Format(time.RFC3339), + } + copyOpenAIAutoResetAttempt(checking, state) + if err := s.persistState(ctx, accountID, checking); err != nil { + return err + } + + usage, err := s.quota.QueryUsage(ctx, accountID) + if err != nil || usage == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_QUERY_FAILED", err) + } + if err := s.persistFreshUsage(ctx, accountID, usage, now); err != nil { + return s.failState(ctx, accountID, checking, "USAGE_SNAPSHOT_WRITE_FAILED", err) + } + if usage.RateLimitResetCredits == nil { + return s.failState(ctx, accountID, checking, "RESET_CREDIT_DETAILS_UNAVAILABLE", nil) + } + + // 查询期间管理员可能关闭开关;消费前重新读取账号,确保尚未发出的任务可取消。 + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + config = ResolveOpenAIAutoResetCreditConfig(account) + if !config.Enabled { + return nil + } + assessment = s.assessUsage(usage, account, config, now) + available := usage.RateLimitResetCredits.AvailableCount + if !assessment.resetReached { + status := OpenAIAutoResetStatusNoCredit + if available > 0 { + status = OpenAIAutoResetStatusAvailable + } + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: status, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + }) + } + if available <= 0 { + return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: now.UTC().Format(time.RFC3339), + LastResultAt: now.UTC().Format(time.RFC3339), + ErrorCode: "NO_RESET_CREDIT", + }) + } + + cycleSeed := openAIAutoResetCycleSeed(usage) + cycleHash := shortOpenAIAutoResetHash(cycleSeed) + candidate, selectErr := selectOpenAIAutoResetCandidate(usage.autoResetCandidates, available, state, cycleHash) + if selectErr != nil { + failed := checking + failed.AvailableCount = available + failed.TriggerWindow = assessment.triggerWindow + failed.AttemptCycleHash = cycleHash + return s.failState(ctx, accountID, failed, infraerrors.Reason(selectErr), selectErr) + } + creditHash := shortOpenAIAutoResetHash(candidate.ID) + stableKey := fmt.Sprintf("oarc:%d:%s:%s", accountID, creditHash, cycleHash) + redeemRequestID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(stableKey)).String() + resetting := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusResetting, + TriggerWindow: assessment.triggerWindow, + AvailableCount: available, + CheckedAt: now.UTC().Format(time.RFC3339), + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if err := s.persistState(ctx, accountID, resetting); err != nil { + return err + } + + account, err = s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil || !ResolveOpenAIAutoResetCreditConfig(account).Enabled { + return err + } + result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{ + Scope: "openai_auto_reset_credit", + ActorScope: fmt.Sprintf("account:%d", accountID), + Method: http.MethodPost, + Route: "/system/openai/reset-credit/auto", + IdempotencyKey: stableKey, + Payload: map[string]any{ + "account_id": accountID, + "credit_hash": creditHash, + "cycle_hash": cycleHash, + }, + TTL: openAIAutoResetAttemptTTL, + RequireKey: true, + }, func(execCtx context.Context) (any, error) { + resetResult, resetErr := s.quota.ResetCreditTargeted(execCtx, accountID, candidate.ID, redeemRequestID) + if resetErr != nil { + return nil, resetErr + } + if resetResult == nil { + return nil, infraerrors.InternalServer("OPENAI_AUTO_RESET_EMPTY_RESULT", "automatic reset returned an empty result") + } + // 幂等表只保存脱敏结果,避免上游返回的卡 ID 被持久化到响应体列。 + return openAIAutoResetConsumeResult{Code: resetResult.Code, WindowsReset: resetResult.WindowsReset}, nil + }) + if err != nil { + // 另一个实例已持有同一周期的兑换时保持 resetting,等待下一轮读取同一 + // 幂等结果;不能把并发冲突误报成上游消费失败,更不能改选下一张卡。 + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(ErrIdempotencyInProgress) || reason == infraerrors.Reason(ErrIdempotencyRetryBackoff) { + return nil + } + s.recordAudit(accountID, assessment, available, "failed", 0, infraerrors.Reason(err)) + return s.failState(ctx, accountID, resetting, infraerrors.Reason(err), err) + } + + consumeResult := decodeOpenAIAutoResetConsumeResult(result.Data) + if strings.EqualFold(strings.TrimSpace(consumeResult.Code), "no_credit") { + noCreditAt := time.Now().UTC().Format(time.RFC3339) + noCredit := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, + TriggerWindow: assessment.triggerWindow, + AvailableCount: 0, + CheckedAt: noCreditAt, + LastResultAt: noCreditAt, + ErrorCode: "NO_RESET_CREDIT", + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + s.recordAudit(accountID, assessment, available, "no_credit", 0, noCredit.ErrorCode) + return s.persistState(ctx, accountID, noCredit) + } + postCtx, cancelPost := context.WithTimeout(context.WithoutCancel(ctx), 8*time.Second) + post := RunOpenAIQuotaResetPostProcess(postCtx, accountID, s.quota, s.recoverer, s.accountRepo.GetByID) + cancelPost() + if !post.AccountStateRecovered || post.WarningCode != "" { + code := post.WarningCode + if code == "" { + code = OpenAIQuotaResetWarningAccountRecoveryFailed + } + s.recordAudit(accountID, assessment, available, "recovery_failed", consumeResult.WindowsReset, code) + return s.failState(ctx, accountID, resetting, code, nil) + } + + successAt := time.Now().UTC().Format(time.RFC3339) + success := &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusSuccess, + TriggerWindow: assessment.triggerWindow, + AvailableCount: max(0, available-1), + CheckedAt: successAt, + LastResultAt: successAt, + AttemptCycleHash: cycleHash, + AttemptCreditHash: creditHash, + } + if post.Quota != nil && post.Quota.RateLimitResetCredits != nil { + success.AvailableCount = post.Quota.RateLimitResetCredits.AvailableCount + } + if err := s.persistState(ctx, accountID, success); err != nil { + return err + } + s.recordAudit(accountID, assessment, available, "success", consumeResult.WindowsReset, "") + slog.Info("openai_auto_reset_credit_success", + "account_id", accountID, + "trigger_window", assessment.triggerWindow, + "threshold_5h", assessment.threshold5h, + "threshold_7d", assessment.threshold7d, + "utilization_5h", assessment.utilization5h, + "utilization_7d", assessment.utilization7d, + "windows_reset", consumeResult.WindowsReset, + ) + return nil +} + +type openAIAutoResetConsumeResult struct { + Code string `json:"code"` + WindowsReset int `json:"windows_reset"` +} + +func decodeOpenAIAutoResetConsumeResult(value any) openAIAutoResetConsumeResult { + if typed, ok := value.(openAIAutoResetConsumeResult); ok { + return typed + } + raw, _ := json.Marshal(value) + var decoded openAIAutoResetConsumeResult + _ = json.Unmarshal(raw, &decoded) + return decoded +} + +func (s *OpenAIQuotaAutoResetService) assessExtra(account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + utilization5h, _ := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) + utilization7d, _ := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) assessUsage(usage *OpenAIQuotaUsage, account *Account, config OpenAIAutoResetCreditConfig, now time.Time) openAIAutoResetAssessment { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + utilization5h := readOpenAIQuotaUsedPercent(updates, "5h") / 100 + utilization7d := readOpenAIQuotaUsedPercent(updates, "7d") / 100 + return s.buildAssessment(account, config, utilization5h, utilization7d) +} + +func (s *OpenAIQuotaAutoResetService) buildAssessment(account *Account, config OpenAIAutoResetCreditConfig, utilization5h, utilization7d float64) openAIAutoResetAssessment { + assessment := openAIAutoResetAssessment{ + utilization5h: utilization5h, + utilization7d: utilization7d, + threshold5h: config.Threshold5h, + threshold7d: config.Threshold7d, + } + reset5h := utilization5h >= config.Threshold5h + reset7d := utilization7d >= config.Threshold7d + assessment.resetReached = reset5h || reset7d + assessment.triggerWindow = joinOpenAIAutoResetWindows(reset5h, reset7d) + + pause5h, pause7d := resolveOpenAIQuotaAutoPauseThresholds(context.Background(), account) + if s.settings != nil { + pause5h, pause7d = resolveOpenAIQuotaAutoPauseThresholds( + withOpenAIQuotaAutoPauseSettings(context.Background(), s.settings.GetOpenAIQuotaAutoPauseSettings(context.Background())), + account, + ) + } + pauseReached5h := !resolveAccountExtraBool(account.Extra, "auto_pause_5h_disabled") && pause5h > 0 && utilization5h >= pause5h + pauseReached7d := !resolveAccountExtraBool(account.Extra, "auto_pause_7d_disabled") && pause7d > 0 && utilization7d >= pause7d + assessment.pauseReached = pauseReached5h || pauseReached7d || assessment.resetReached + if assessment.triggerWindow == "" { + assessment.triggerWindow = joinOpenAIAutoResetWindows(pauseReached5h, pauseReached7d) + } + return assessment +} + +func joinOpenAIAutoResetWindows(fiveHour, sevenDay bool) string { + switch { + case fiveHour && sevenDay: + return "5h+7d" + case fiveHour: + return "5h" + case sevenDay: + return "7d" + default: + return "" + } +} + +func buildOpenAIAutoResetUsageUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any { + if usage == nil || usage.RateLimit == nil { + return nil + } + rateLimit := usage.RateLimit + snapshot := &OpenAICodexUsageSnapshot{UpdatedAt: now.UTC().Format(time.RFC3339)} + applyWindow := func(window *OpenAIRateLimitWindow, primary bool) { + if window == nil { + return + } + used := window.UsedPercent + resetAfter := int(window.ResetAfterSeconds) + windowMinutes := int(window.LimitWindowSeconds / 60) + if primary { + snapshot.PrimaryUsedPercent = &used + snapshot.PrimaryResetAfterSeconds = &resetAfter + snapshot.PrimaryWindowMinutes = &windowMinutes + } else { + snapshot.SecondaryUsedPercent = &used + snapshot.SecondaryResetAfterSeconds = &resetAfter + snapshot.SecondaryWindowMinutes = &windowMinutes + } + } + applyWindow(rateLimit.PrimaryWindow, true) + applyWindow(rateLimit.SecondaryWindow, false) + return buildCodexUsageExtraUpdates(snapshot, now) +} + +func (s *OpenAIQuotaAutoResetService) persistFreshUsage(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage, now time.Time) error { + updates := buildOpenAIAutoResetUsageUpdates(usage, now) + if len(updates) > 0 { + if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil { + return err + } + } + return s.quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits) +} + +func selectOpenAIAutoResetCandidate(candidates []openAIAutoResetCreditCandidate, available int, previous *OpenAIAutoResetCreditState, cycleHash string) (openAIAutoResetCreditCandidate, error) { + if available <= 0 { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_NO_CREDIT", "no reset credit is available") + } + if len(candidates) < available { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") + } + for _, candidate := range candidates { + if _, err := time.Parse(time.RFC3339, candidate.ExpiresAt); err != nil { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_EXPIRY_INVALID", "reset credit expiration is invalid") + } + } + sorted := append([]openAIAutoResetCreditCandidate(nil), candidates...) + sort.SliceStable(sorted, func(i, j int) bool { + left, leftErr := time.Parse(time.RFC3339, sorted[i].ExpiresAt) + right, rightErr := time.Parse(time.RFC3339, sorted[j].ExpiresAt) + if leftErr != nil { + return false + } + if rightErr != nil { + return true + } + return left.Before(right) + }) + if previous != nil && previous.AttemptCycleHash == cycleHash && previous.AttemptCreditHash != "" { + for _, candidate := range sorted { + if shortOpenAIAutoResetHash(candidate.ID) == previous.AttemptCreditHash { + if strings.TrimSpace(candidate.ID) == "" { + break + } + return candidate, nil + } + } + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_ORIGINAL_CREDIT_UNAVAILABLE", "the original reset credit cannot be confirmed; refusing to switch credits") + } + if len(sorted) == 0 || strings.TrimSpace(sorted[0].ID) == "" { + return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_ID_MISSING", "the earliest reset credit has no official id") + } + return sorted[0], nil +} + +func openAIAutoResetCycleSeed(usage *OpenAIQuotaUsage) string { + if usage == nil || usage.RateLimit == nil { + return "5h:0|7d:0" + } + var fiveHour, sevenDay int64 + for _, window := range []*OpenAIRateLimitWindow{usage.RateLimit.PrimaryWindow, usage.RateLimit.SecondaryWindow} { + if window == nil { + continue + } + resetAt := window.ResetAt + if resetAt <= 0 { + resetAt = usage.FetchedAt + window.ResetAfterSeconds + } + if window.LimitWindowSeconds <= 6*60*60 { + fiveHour = resetAt + } else { + sevenDay = resetAt + } + } + return fmt.Sprintf("5h:%d|7d:%d", fiveHour, sevenDay) +} + +func shortOpenAIAutoResetHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:12]) +} + +func openAIAutoResetSnapshotStale(extra map[string]any, now time.Time) bool { + if len(extra) == 0 { + return true + } + raw, ok := extra["codex_usage_updated_at"] + if !ok { + return true + } + updatedAt, err := parseTime(fmt.Sprint(raw)) + return err != nil || now.Sub(updatedAt) >= openAIAutoResetSnapshotTTL +} + +func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditState { + if len(extra) == 0 { + return nil + } + raw, ok := extra[OpenAIAutoResetCreditStateExtraKey] + if !ok || raw == nil { + return nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil + } + var state OpenAIAutoResetCreditState + if err := json.Unmarshal(encoded, &state); err != nil || state.Status == "" { + return nil + } + return &state +} + +func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool { + if state == nil || state.CheckedAt == "" { + return true + } + checkedAt, err := time.Parse(time.RFC3339, state.CheckedAt) + return err != nil || now.Sub(checkedAt) >= openAIAutoResetSnapshotTTL +} + +func stateAvailableCount(state *OpenAIAutoResetCreditState) int { + if state == nil { + return 0 + } + return state.AvailableCount +} + +func copyOpenAIAutoResetAttempt(target, source *OpenAIAutoResetCreditState) { + if target == nil || source == nil { + return + } + target.AttemptCycleHash = source.AttemptCycleHash + target.AttemptCreditHash = source.AttemptCreditHash +} + +func (s *OpenAIQuotaAutoResetService) persistState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState) error { + if state == nil { + return nil + } + return s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{OpenAIAutoResetCreditStateExtraKey: state}) +} + +func (s *OpenAIQuotaAutoResetService) failState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState, code string, cause error) error { + if state == nil { + state = &OpenAIAutoResetCreditState{} + } + if strings.TrimSpace(code) == "" { + code = "OPENAI_AUTO_RESET_FAILED" + } + state.Status = OpenAIAutoResetStatusFailed + state.ErrorCode = code + state.LastResultAt = time.Now().UTC().Format(time.RFC3339) + if err := s.persistState(ctx, accountID, state); err != nil { + return err + } + slog.Warn("openai_auto_reset_credit_failed", + "account_id", accountID, + "trigger_window", state.TriggerWindow, + "available_count", state.AvailableCount, + "error_code", code, + ) + if cause != nil { + return cause + } + return infraerrors.Conflict(code, "automatic reset credit operation failed") +} + +func (s *OpenAIQuotaAutoResetService) recordAudit(accountID int64, assessment openAIAutoResetAssessment, available int, resultCode string, windowsReset int, errorCode string) { + if s.audit == nil { + return + } + statusCode := http.StatusOK + if resultCode != "success" { + statusCode = http.StatusConflict + } + s.audit.Record(&AuditLog{ + ActorEmail: "system", + ActorRole: "system", + AuthMethod: "system", + Action: "system.openai.reset_credit.auto", + Method: "SYSTEM", + Path: fmt.Sprintf("/system/openai/accounts/%d/auto-reset-credit", accountID), + StatusCode: statusCode, + Extra: map[string]any{ + "account_id": accountID, + "trigger_window": assessment.triggerWindow, + "threshold_5h": assessment.threshold5h, + "threshold_7d": assessment.threshold7d, + "utilization_5h": assessment.utilization5h, + "utilization_7d": assessment.utilization7d, + "available_count": available, + "result_code": resultCode, + "windows_reset": windowsReset, + "error_code": errorCode, + }, + }) +} + +var openAIAutoResetNotifierRegistry struct { + sync.RWMutex + service *OpenAIQuotaAutoResetService +} + +func setOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + openAIAutoResetNotifierRegistry.service = service + openAIAutoResetNotifierRegistry.Unlock() +} + +func clearOpenAIAutoResetNotifier(service *OpenAIQuotaAutoResetService) { + openAIAutoResetNotifierRegistry.Lock() + if openAIAutoResetNotifierRegistry.service == service { + openAIAutoResetNotifierRegistry.service = nil + } + openAIAutoResetNotifierRegistry.Unlock() +} + +func notifyOpenAIAutoReset(accountID int64) { + openAIAutoResetNotifierRegistry.RLock() + service := openAIAutoResetNotifierRegistry.service + openAIAutoResetNotifierRegistry.RUnlock() + if service != nil { + service.Notify(accountID) + } +} + +// NotifyOpenAIAutoResetCredit 供额度查询入口发送轻量信号;不执行同步上游请求。 +func NotifyOpenAIAutoResetCredit(accountID int64) { + notifyOpenAIAutoReset(accountID) +} diff --git a/backend/internal/service/openai_quota_auto_reset_config.go b/backend/internal/service/openai_quota_auto_reset_config.go new file mode 100644 index 0000000000..e891049795 --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_config.go @@ -0,0 +1,148 @@ +package service + +import ( + "encoding/json" + "math" + "net/http" + "strconv" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIAutoResetCreditEnabledExtraKey = "auto_reset_credit_enabled" + OpenAIAutoResetCredit5hThresholdExtraKey = "auto_reset_credit_5h_threshold" + OpenAIAutoResetCredit7dThresholdExtraKey = "auto_reset_credit_7d_threshold" + OpenAIAutoResetCreditStateExtraKey = "codex_auto_reset_credit_state" + + openAIAutoResetCreditDefaultThreshold = 1.0 + openAIAutoResetCreditMinimumThreshold = 0.001 +) + +// OpenAIAutoResetCreditConfig 是账号级自动用卡配置。阈值采用 0-1 比例, +// 避免后端调度与前端百分比展示混用同一数值语义。 +type OpenAIAutoResetCreditConfig struct { + Enabled bool + Threshold5h float64 + Threshold7d float64 +} + +// ResolveOpenAIAutoResetCreditConfig 只接受 OpenAI OAuth 母账号;历史账号未配置时 +// 始终保持关闭,防止升级后产生意外消费。 +func ResolveOpenAIAutoResetCreditConfig(account *Account) OpenAIAutoResetCreditConfig { + config := OpenAIAutoResetCreditConfig{ + Threshold5h: openAIAutoResetCreditDefaultThreshold, + Threshold7d: openAIAutoResetCreditDefaultThreshold, + } + if !isOpenAIAutoResetCreditAccount(account) || account.Extra == nil { + return config + } + config.Enabled = resolveAccountExtraBool(account.Extra, OpenAIAutoResetCreditEnabledExtraKey) + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit5hThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold5h = value + } + if value, ok := resolveAccountExtraNumber(account.Extra, OpenAIAutoResetCredit7dThresholdExtraKey); ok && isValidOpenAIAutoResetThreshold(value) { + config.Threshold7d = value + } + return config +} + +func isOpenAIAutoResetCreditAccount(account *Account) bool { + return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth && !account.IsShadow() +} + +// normalizeOpenAIAutoResetCreditExtra 校验管理请求中的配置并剥离服务运行态。 +// enabled=true 时补齐两个 100% 默认阈值;关闭时保留已设置阈值,方便再次开启。 +func normalizeOpenAIAutoResetCreditExtra(platform, accountType string, isShadow bool, extra map[string]any) (map[string]any, error) { + if extra == nil { + return nil, nil + } + normalized := cloneOpenAIAutoResetExtra(extra) + delete(normalized, OpenAIAutoResetCreditStateExtraKey) + + _, hasEnabled := normalized[OpenAIAutoResetCreditEnabledExtraKey] + _, has5h := normalized[OpenAIAutoResetCredit5hThresholdExtraKey] + _, has7d := normalized[OpenAIAutoResetCredit7dThresholdExtraKey] + if !hasEnabled && !has5h && !has7d { + return normalized, nil + } + if platform != PlatformOpenAI || accountType != AccountTypeOAuth || isShadow { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ACCOUNT_INVALID", "automatic reset credits are only supported for OpenAI OAuth parent accounts") + } + + enabled := false + if hasEnabled { + value, ok := normalized[OpenAIAutoResetCreditEnabledExtraKey].(bool) + if !ok { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_ENABLED_INVALID", "auto_reset_credit_enabled must be a boolean") + } + enabled = value + } + for key, present := range map[string]bool{ + OpenAIAutoResetCredit5hThresholdExtraKey: has5h, + OpenAIAutoResetCredit7dThresholdExtraKey: has7d, + } { + if !present { + if enabled { + normalized[key] = openAIAutoResetCreditDefaultThreshold + } + continue + } + value, ok := parseOpenAIAutoResetThreshold(normalized[key]) + if !ok || !isValidOpenAIAutoResetThreshold(value) { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_AUTO_RESET_CREDIT_THRESHOLD_INVALID", "%s must be between 0.001 and 1.0", key) + } + normalized[key] = value + } + return normalized, nil +} + +func stripOpenAIAutoResetCreditManagedExtra(extra map[string]any, stripConfig bool) map[string]any { + if extra == nil { + return nil + } + delete(extra, OpenAIAutoResetCreditStateExtraKey) + if stripConfig { + delete(extra, OpenAIAutoResetCreditEnabledExtraKey) + delete(extra, OpenAIAutoResetCredit5hThresholdExtraKey) + delete(extra, OpenAIAutoResetCredit7dThresholdExtraKey) + } + return extra +} + +func parseOpenAIAutoResetThreshold(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func isValidOpenAIAutoResetThreshold(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= openAIAutoResetCreditMinimumThreshold && value <= 1 +} + +func cloneOpenAIAutoResetExtra(source map[string]any) map[string]any { + if source == nil { + return nil + } + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} diff --git a/backend/internal/service/openai_quota_auto_reset_test.go b/backend/internal/service/openai_quota_auto_reset_test.go new file mode 100644 index 0000000000..ad2dc965aa --- /dev/null +++ b/backend/internal/service/openai_quota_auto_reset_test.go @@ -0,0 +1,332 @@ +package service + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeOpenAIAutoResetCreditExtra(t *testing.T) { + t.Run("历史账号默认关闭", func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + config := ResolveOpenAIAutoResetCreditConfig(account) + require.False(t, config.Enabled) + require.Equal(t, 1.0, config.Threshold5h) + require.Equal(t, 1.0, config.Threshold7d) + }) + + t.Run("开启时补齐两个百分百阈值并剥离运行态", func(t *testing.T) { + extra, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCreditStateExtraKey: map[string]any{"status": "success"}, + }) + require.NoError(t, err) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit5hThresholdExtraKey]) + require.Equal(t, 1.0, extra[OpenAIAutoResetCredit7dThresholdExtraKey]) + require.NotContains(t, extra, OpenAIAutoResetCreditStateExtraKey) + }) + + t.Run("阈值和账号类型严格校验", func(t *testing.T) { + _, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.0009, + }) + require.Error(t, err) + + _, err = normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, true, map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + }) + require.Error(t, err) + }) +} + +func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T) { + now := time.Now().UTC() + baseExtra := map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "auto_pause_5h_threshold": 0.8, + "auto_pause_7d_disabled": true, + "codex_5h_used_percent": 90.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + } + + t.Run("卡状态未知时暂停并触发异步查询", func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: cloneOpenAIAutoResetExtra(baseExtra)} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_credit_check_5h", decision.reason) + }) + + t.Run("明确有卡时允许继续到用卡阈值", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) + + t.Run("达到用卡阈值后即使有卡也退出调度", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, decision := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.True(t, paused) + require.Equal(t, "quota_auto_reset_pending_5h", decision.reason) + }) + + t.Run("自然窗口重置后清除动态阻塞", func(t *testing.T) { + extra := cloneOpenAIAutoResetExtra(baseExtra) + extra["codex_5h_used_percent"] = 100.0 + extra["codex_5h_reset_at"] = now.Add(-time.Second).Format(time.RFC3339) + extra[OpenAIAutoResetCreditStateExtraKey] = OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusFailed, TriggerWindow: "5h", ErrorCode: "RESET_FAILED", + } + account := &Account{ID: 4, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra} + paused, _ := shouldAutoPauseOpenAIAccountByQuota(context.Background(), account) + require.False(t, paused) + }) +} + +func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { + candidates := []openAIAutoResetCreditCandidate{ + {ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"}, + {ID: "earlier", ExpiresAt: "2026-09-01T00:00:00Z"}, + } + selected, err := selectOpenAIAutoResetCandidate(candidates, 2, nil, "cycle-a") + require.NoError(t, err) + require.Equal(t, "earlier", selected.ID) + + _, err = selectOpenAIAutoResetCandidate([]openAIAutoResetCreditCandidate{ + {ExpiresAt: "2026-09-01T00:00:00Z"}, + }, 1, nil, "cycle-a") + require.Error(t, err) + + _, err = selectOpenAIAutoResetCandidate(candidates, 2, &OpenAIAutoResetCreditState{ + AttemptCycleHash: "cycle-a", AttemptCreditHash: shortOpenAIAutoResetHash("missing"), + }, "cycle-a") + require.Error(t, err, "模糊结果后原卡消失时不得切换下一张卡") +} + +func TestOpenAIQuotaAutoResetService_AssessesIndependentWindows(t *testing.T) { + service := &OpenAIQuotaAutoResetService{} + account := &Account{Extra: map[string]any{ + "auto_pause_5h_disabled": true, + "auto_pause_7d_disabled": true, + }} + config := OpenAIAutoResetCreditConfig{Enabled: true, Threshold5h: 0.8, Threshold7d: 0.9} + tests := []struct { + name string + fiveHour float64 + sevenDay float64 + wantWindow string + }{ + {name: "5h", fiveHour: 0.8, sevenDay: 0.2, wantWindow: "5h"}, + {name: "7d", fiveHour: 0.2, sevenDay: 0.9, wantWindow: "7d"}, + {name: "同时触发", fiveHour: 0.95, sevenDay: 0.95, wantWindow: "5h+7d"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assessment := service.buildAssessment(account, config, test.fiveHour, test.sevenDay) + require.True(t, assessment.resetReached) + require.Equal(t, test.wantWindow, assessment.triggerWindow) + }) + } +} + +type autoResetTestAccountRepo struct { + AccountRepository + mu sync.Mutex + account *Account +} + +func (r *autoResetTestAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := *r.account + copy.Extra = cloneOpenAIAutoResetExtra(r.account.Extra) + return ©, nil +} + +func (r *autoResetTestAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.account.Extra == nil { + r.account.Extra = make(map[string]any) + } + for key, value := range updates { + r.account.Extra[key] = value + } + return nil +} + +type autoResetTestQuota struct { + usage *OpenAIQuotaUsage + resetCalls atomic.Int32 + resetEntered chan struct{} + releaseReset chan struct{} + enterOnce sync.Once + mu sync.Mutex + resetArgs [][2]string + failFirst bool +} + +func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) { + copy := *q.usage + return ©, nil +} + +func (q *autoResetTestQuota) CacheResetCreditsSnapshot(context.Context, int64, *OpenAIRateLimitResetCredits) error { + return nil +} + +func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + if creditID == "" || redeemRequestID == "" { + panic("targeted reset identifiers must be present") + } + call := q.resetCalls.Add(1) + q.mu.Lock() + q.resetArgs = append(q.resetArgs, [2]string{creditID, redeemRequestID}) + q.mu.Unlock() + if q.failFirst && call == 1 { + return nil, context.DeadlineExceeded + } + if q.resetEntered != nil { + q.enterOnce.Do(func() { close(q.resetEntered) }) + } + if q.releaseReset != nil { + <-q.releaseReset + } + return &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 2}, nil +} + +type autoResetTestRecoverer struct{} + +func (autoResetTestRecoverer) RecoverAccountState(context.Context, int64, AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) { + return &SuccessfulTestRecoveryResult{ClearedRateLimit: true}, nil +} + +func TestOpenAIQuotaAutoResetService_ConcurrentInstancesConsumeOnce(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 99, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_7d_used_percent": 10.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + "codex_7d_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + usage := &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 10, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 86400, ResetAt: now.Add(24 * time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "credit-sensitive-id", ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + } + quota := &autoResetTestQuota{usage: usage, resetEntered: make(chan struct{}), releaseReset: make(chan struct{})} + idempotencyRepo := newInMemoryIdempotencyRepo() + config := DefaultIdempotencyConfig() + config.ObserveOnly = false + config.ProcessingTimeout = time.Second + serviceA := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + serviceB := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(idempotencyRepo, config), nil, nil, nil) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = serviceA.evaluateAccount(context.Background(), account.ID) + }() + <-quota.resetEntered + go func() { + defer wg.Done() + _ = serviceB.evaluateAccount(context.Background(), account.ID) + }() + time.Sleep(50 * time.Millisecond) + close(quota.releaseReset) + wg.Wait() + + require.Equal(t, int32(1), quota.resetCalls.Load()) + repo.mu.Lock() + state := openAIAutoResetStateFromExtra(repo.account.Extra) + repo.mu.Unlock() + require.NotNil(t, state) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + encodedState, err := json.Marshal(state) + require.NoError(t, err) + require.NotContains(t, string(encodedState), "credit-sensitive-id") +} + +func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 1.0, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 100.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + expiresAt := now.Add(48 * time.Hour).Format(time.RFC3339) + quota := &autoResetTestQuota{ + failFirst: true, + usage: &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 100, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: expiresAt}}, + }, + autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "retry-credit", ExpiresAt: expiresAt}}, + }, + } + idempotencyConfig := DefaultIdempotencyConfig() + idempotencyConfig.ObserveOnly = false + idempotencyConfig.FailedRetryBackoff = 0 + service := NewOpenAIQuotaAutoResetService( + repo, + quota, + autoResetTestRecoverer{}, + NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), + nil, nil, nil, + ) + + require.Error(t, service.evaluateAccount(context.Background(), account.ID)) + require.NoError(t, service.evaluateAccount(context.Background(), account.ID)) + quota.mu.Lock() + args := append([][2]string(nil), quota.resetArgs...) + quota.mu.Unlock() + require.Len(t, args, 2) + require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id") +} diff --git a/backend/internal/service/openai_quota_reset_credits.go b/backend/internal/service/openai_quota_reset_credits.go index 9ff7e70f16..b87e3fd0d1 100644 --- a/backend/internal/service/openai_quota_reset_credits.go +++ b/backend/internal/service/openai_quota_reset_credits.go @@ -8,6 +8,9 @@ import ( ) type openAIRateLimitResetCreditDetailPayload struct { + ID string `json:"id,omitempty"` + CreditID string `json:"credit_id,omitempty"` + CreditIDCamel string `json:"creditId,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` ExpiresAtCamel string `json:"expiresAt,omitempty"` ResetType string `json:"reset_type,omitempty"` @@ -29,6 +32,14 @@ type openAIRateLimitResetCreditDetails struct { AvailableCreditCount int CreditListPresent bool Credits []OpenAIRateLimitResetCreditDetail + AutoResetCandidates []openAIAutoResetCreditCandidate +} + +// openAIAutoResetCreditCandidate 仅在服务内部流转。上游卡 ID 不进入 API DTO、 +// 账号 extra 或日志,避免管理端响应扩大敏感标识暴露面。 +type openAIAutoResetCreditCandidate struct { + ID string + ExpiresAt string } func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCreditDetails, error) { @@ -64,6 +75,7 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr } credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits)) + autoResetCandidates := make([]openAIAutoResetCreditCandidate, 0, len(rawCredits)) availableCreditCount := 0 for _, raw := range rawCredits { if raw == nil { @@ -88,12 +100,24 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr continue } credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt}) + creditID := strings.TrimSpace(raw.ID) + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditID) + } + if creditID == "" { + creditID = strings.TrimSpace(raw.CreditIDCamel) + } + autoResetCandidates = append(autoResetCandidates, openAIAutoResetCreditCandidate{ + ID: creditID, + ExpiresAt: expiresAt, + }) } return openAIRateLimitResetCreditDetails{ AvailableCount: availableCount, AvailableCreditCount: availableCreditCount, CreditListPresent: creditListPresent, Credits: credits, + AutoResetCandidates: autoResetCandidates, }, nil } diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 34f87f9a2f..2deb01ed89 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -14,8 +14,8 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t "availableCount":"2", "credits":[ {"reset_type":"codex_rate_limits","status":"redeemed","expires_at":"2026-07-01T04:05:06Z"}, - {"reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, - {"resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, + {"id":"credit-later","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"}, + {"creditId":"credit-earlier","resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"}, {"reset_type":"other","status":"available","expires_at":"2026-07-02T04:05:06Z"} ] }`) @@ -28,6 +28,10 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t {ExpiresAt: "2026-07-04T04:05:06Z"}, {ExpiresAt: "2026-07-03T04:05:06Z"}, }, details.Credits) + require.Equal(t, []openAIAutoResetCreditCandidate{ + {ID: "credit-later", ExpiresAt: "2026-07-04T04:05:06Z"}, + {ID: "credit-earlier", ExpiresAt: "2026-07-03T04:05:06Z"}, + }, details.AutoResetCandidates) } func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { diff --git a/backend/internal/service/openai_quota_reset_workflow.go b/backend/internal/service/openai_quota_reset_workflow.go new file mode 100644 index 0000000000..430b29618b --- /dev/null +++ b/backend/internal/service/openai_quota_reset_workflow.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "log/slog" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + OpenAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" + OpenAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" + OpenAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" +) + +type openAIQuotaResetWorkflowQuota interface { + QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error +} + +type openAIQuotaResetWorkflowRecoverer interface { + RecoverAccountState(ctx context.Context, accountID int64, options AccountRecoveryOptions) (*SuccessfulTestRecoveryResult, error) +} + +// OpenAIQuotaResetPostProcessResult 汇总手动和自动用卡后的共享恢复结果。 +// WarningCode 允许上游消费成功但本地恢复部分失败时仍然准确呈现状态。 +type OpenAIQuotaResetPostProcessResult struct { + Quota *OpenAIQuotaUsage + Account *Account + CacheRefreshed bool + AccountStateRecovered bool + WarningCode string +} + +// RunOpenAIQuotaResetPostProcess 按“解除限流、刷新额度缓存、刷新账号行”的固定顺序 +// 执行消费后恢复,避免手动和自动入口在失败语义上逐渐分叉。 +func RunOpenAIQuotaResetPostProcess( + ctx context.Context, + accountID int64, + quota openAIQuotaResetWorkflowQuota, + recoverer openAIQuotaResetWorkflowRecoverer, + loadAccount func(context.Context, int64) (*Account, error), +) OpenAIQuotaResetPostProcessResult { + result := OpenAIQuotaResetPostProcessResult{} + if recoverer == nil { + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + if _, err := recoverer.RecoverAccountState(ctx, accountID, AccountRecoveryOptions{InvalidateToken: true}); err != nil { + slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningAccountRecoveryFailed + return result + } + result.AccountStateRecovered = true + + if quota != nil { + usage, usageErr := quota.QueryUsage(ctx, accountID) + switch { + case usageErr != nil || usage == nil: + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(usageErr)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + default: + if err := quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed + } else { + result.Quota = usage + result.CacheRefreshed = true + } + } + } + + if loadAccount == nil { + return result + } + account, err := loadAccount(ctx, accountID) + if err != nil { + slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err)) + if result.WarningCode == "" { + result.WarningCode = OpenAIQuotaResetWarningAccountRefreshFailed + } + return result + } + result.Account = account + return result +} diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 58bf4659f5..6e40c497fc 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -87,6 +87,7 @@ type OpenAIQuotaUsage struct { AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` FetchedAt int64 `json:"fetched_at"` + autoResetCandidates []openAIAutoResetCreditCandidate } // OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the @@ -180,6 +181,10 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* continue } status := resp.StatusCode + if isOpenAIAutoResetContext(ctx) { + slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "source", "auto_reset") + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(ctx, accountID, resp.String()), 240) slog.Warn("openai_quota_query_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) @@ -190,6 +195,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* payload.FetchedAt = time.Now().Unix() details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID) if details != nil { + payload.autoResetCandidates = details.AutoResetCandidates hasDetailCount := details.AvailableCount != nil if payload.RateLimitResetCredits == nil { payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{} @@ -274,6 +280,25 @@ func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client // The redeem_request_id is auto-generated (uuid-like) — upstream uses it for // idempotency. Returns the consumed credit metadata so the UI can refresh. func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) (*OpenAIQuotaResetResult, error) { + redeemRequestID, err := generateRedeemRequestID() + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) + } + return s.resetCredit(ctx, accountID, "", redeemRequestID, false) +} + +// ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时 +// 复用同一组参数;本方法不会回退到不带 credit_id 的旧消费方式。 +func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { + creditID = strings.TrimSpace(creditID) + redeemRequestID = strings.TrimSpace(redeemRequestID) + if creditID == "" || redeemRequestID == "" { + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required") + } + return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true) +} + +func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) { // Shadow guard: resetting credits via a shadow account would silently // operate on the parent's quota; that is surprising and unwanted. Callers // must reset the parent account directly. @@ -297,11 +322,6 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, err } - redeemRequestID, err := generateRedeemRequestID() - if err != nil { - return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) - } - client, err := s.privacyClientFactory(proxyURL) if err != nil { return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_CLIENT_ERROR", "failed to build upstream client: %v", err) @@ -318,10 +338,14 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_QUOTA_AUTH_FAILED", "failed to build upstream authentication: %v", headerErr) } headers["content-type"] = "application/json" + body := map[string]string{"redeem_request_id": redeemRequestID} + if targeted { + body["credit_id"] = creditID + } resp, err := client.R(). SetContext(callCtx). SetHeaders(headers). - SetBody(map[string]string{"redeem_request_id": redeemRequestID}). + SetBody(body). SetSuccessResult(&payload). Post(chatGPTRateLimitResetURL) if err != nil { @@ -336,6 +360,10 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( continue } status := resp.StatusCode + if targeted { + slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status) + return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status) + } body := truncate(s.redactQuotaErrorBody(callCtx, accountID, resp.String()), 240) slog.Warn("openai_quota_reset_failed", "account_id", accountID, "status", status, "body", body) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d: %s", status, body) diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index a351381cb4..cb47a0c0c5 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -189,6 +189,32 @@ func TestResetCreditShadowRejected(t *testing.T) { "shadow ResetCredit 应映射为 409 Conflict 而非 500") } +func TestResetCreditTargetedSendsStableCreditAndRedeemIDs(t *testing.T) { + account := &Account{ + ID: 203, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "account-targeted"}, + } + repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}} + tokenCache := &stubQuotaTokenCache{tokens: map[string]string{OpenAITokenCacheKey(account): "fake-token"}} + tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil) + var body map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/backend-api/wham/rate-limit-reset-credits/consume", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"code":"ok","windows_reset":1}`)) + })) + defer srv.Close() + + svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv)) + result, err := svc.ResetCreditTargeted(context.Background(), account.ID, "credit-123", "redeem-456") + require.NoError(t, err) + require.Equal(t, "ok", result.Code) + require.Equal(t, map[string]string{ + "credit_id": "credit-123", "redeem_request_id": "redeem-456", + }, body) +} + func TestResetCreditAgentIdentityUsesAssertionAndRecoversInvalidTaskOnce(t *testing.T) { _, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) diff --git a/backend/internal/service/openai_responses_lite_tools.go b/backend/internal/service/openai_responses_lite_tools.go index c5c16ecaa1..7f6c7f58e5 100644 --- a/backend/internal/service/openai_responses_lite_tools.go +++ b/backend/internal/service/openai_responses_lite_tools.go @@ -1,12 +1,22 @@ package service import ( - "encoding/json" "fmt" "reflect" "strings" ) +type openAIResponsesLiteValidationError struct { + param string + message string +} + +func (e *openAIResponsesLiteValidationError) Error() string { return e.message } + +func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error { + return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)} +} + // normalizeOpenAIResponsesLiteTools applies the Responses Lite request // contract: reasoning must cover all turns, and private namespace declarations // use the input.additional_tools carrier. Other top-level tools must belong to @@ -16,18 +26,27 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { if reqBody == nil { return false, nil } + if parallel, exists := reqBody["parallel_tool_calls"]; exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil { if _, ok := rawReasoning.(map[string]any); !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } } rawTools, exists := reqBody["tools"] if !exists || rawTools == nil { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } tools, ok := rawTools.([]any) if !ok { - return false, fmt.Errorf("responses Lite requires tools to be an array") + return false, newOpenAIResponsesLiteValidationError("tools", "responses Lite requires tools to be an array") } topLevelTools := make([]any, 0, len(tools)) @@ -57,7 +76,11 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } } if len(namespaceTools) == 0 { - return ensureOpenAIResponsesLiteReasoningContext(reqBody) + changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody) + if err != nil { + return false, err + } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed) } input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools) @@ -73,6 +96,20 @@ func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) { } else { reqBody["tools"] = topLevelTools } + return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true) +} + +func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) { + parallel, exists := reqBody["parallel_tool_calls"] + if exists { + if _, ok := parallel.(bool); !ok { + return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean") + } + } + if parallel == false { + return changed, nil + } + reqBody["parallel_tool_calls"] = false return true, nil } @@ -84,7 +121,7 @@ func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, er } reasoning, ok := rawReasoning.(map[string]any) if !ok { - return false, fmt.Errorf("responses Lite requires reasoning to be an object") + return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object") } if context, ok := reasoning["context"].(string); ok && context == "all_turns" { return false, nil @@ -201,7 +238,7 @@ func openAIResponsesLiteToolIdentityForError(rawTool any) string { func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) { var requestBody map[string]any - if err := json.Unmarshal(body, &requestBody); err != nil { + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { return body, false, fmt.Errorf("decode responses Lite request body: %w", err) } changed, err := normalizeOpenAIResponsesLiteTools(requestBody) @@ -214,3 +251,29 @@ func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) } return rebuilt, true, nil } + +func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) { + var requestBody map[string]any + if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil { + return body, false, fmt.Errorf("decode responses Lite request body: %w", err) + } + changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false) + if err != nil || !changed { + return body, false, err + } + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return body, false, fmt.Errorf("encode responses Lite request body: %w", err) + } + return rebuilt, true, nil +} + +func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) { + if account == nil || !account.IsOpenAI() { + return body, false, nil + } + if account.IsOpenAIOAuthLike() { + return normalizeOpenAIResponsesLiteToolsPayload(body) + } + return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body) +} diff --git a/backend/internal/service/openai_responses_lite_tools_test.go b/backend/internal/service/openai_responses_lite_tools_test.go index b76e97e272..4088882d24 100644 --- a/backend/internal/service/openai_responses_lite_tools_test.go +++ b/backend/internal/service/openai_responses_lite_tools_test.go @@ -58,6 +58,21 @@ func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"]) } +func TestNormalizeOpenAIResponsesLiteTools_PreservesDeferredFlagsWithToolSearch(t *testing.T) { + reqBody := map[string]any{ + "tools": []any{ + map[string]any{"type": "tool_search"}, + map[string]any{"type": "function", "name": "shell", "defer_loading": true}, + }, + } + + _, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + tools := reqBody["tools"].([]any) + require.Equal(t, "tool_search", tools[0].(map[string]any)["type"]) + require.Equal(t, true, tools[1].(map[string]any)["defer_loading"]) +} + func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) { reqBody := map[string]any{ "tools": []any{map[string]any{ @@ -147,8 +162,111 @@ func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testin changed, err := normalizeOpenAIResponsesLiteTools(reqBody) require.NoError(t, err) - require.False(t, changed) + require.True(t, changed) require.Len(t, reqBody["tools"], 4) + require.Equal(t, false, reqBody["parallel_tool_calls"]) +} + +func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testing.T) { + tests := []struct { + name string + body map[string]any + }{ + { + name: "top-level tools", + body: map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + }, + }, + { + name: "input additional tools", + body: map[string]any{ + "input": []any{map[string]any{ + "type": "additional_tools", + "tools": []any{map[string]any{"type": "namespace", "name": "collaboration"}}, + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed, err := normalizeOpenAIResponsesLiteTools(tt.body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, tt.body["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) { + tests := []struct { + name string + parallel any + include bool + wantChanged bool + }{ + {name: "字段缺失", wantChanged: true}, + {name: "值为 true", parallel: true, include: true, wantChanged: true}, + {name: "值为 false", parallel: false, include: true, wantChanged: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}} + if tt.include { + reqBody["parallel_tool_calls"] = tt.parallel + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.Equal(t, tt.wantChanged, changed) + require.Contains(t, reqBody, "parallel_tool_calls") + require.Equal(t, false, reqBody["parallel_tool_calls"]) + }) + } +} + +func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) { + for _, value := range []any{"false", float64(0), nil, map[string]any{}} { + reqBody := map[string]any{ + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": value, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) + require.Equal(t, value, reqBody["parallel_tool_calls"]) + } + + reqBody := map[string]any{"parallel_tool_calls": []any{}} + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.False(t, changed) +} + +func TestNormalizeOpenAIResponsesLiteTools_ParallelToolCallsIsIdempotent(t *testing.T) { + reqBody := map[string]any{ + "reasoning": map[string]any{"context": "all_turns"}, + "tools": []any{map[string]any{"type": "function", "name": "shell"}}, + "parallel_tool_calls": true, + } + + changed, err := normalizeOpenAIResponsesLiteTools(reqBody) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) + + changed, err = normalizeOpenAIResponsesLiteTools(reqBody) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, false, reqBody["parallel_tool_calls"]) } func TestNormalizeOpenAIResponsesLiteTools_EnsuresReasoningContext(t *testing.T) { @@ -230,6 +348,36 @@ func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t require.False(t, gjson.GetBytes(updated, "tools").Exists()) require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String()) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) +} + +func TestNormalizeOpenAIResponsesLitePayloads_PreserveLargeSequence(t *testing.T) { + body := []byte(`{ + "type":"response.create", + "sequence":900719925474099312345, + "tools":[{"type":"function","name":"lookup"}], + "parallel_tool_calls":true + }`) + tests := []struct { + name string + normalize func([]byte) ([]byte, bool, error) + }{ + {name: "OAuth-like tools normalization", normalize: normalizeOpenAIResponsesLiteToolsPayload}, + {name: "API key parallel normalization", normalize: normalizeOpenAIResponsesLiteParallelToolCallsPayload}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, changed, err := tt.normalize(body) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "900719925474099312345", gjson.GetBytes(updated, "sequence").Raw) + require.True(t, gjson.GetBytes(updated, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) + }) + } } func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) { @@ -282,6 +430,7 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes body := []byte(`{ "model":"gpt-5.6-terra","stream":true,"instructions":"test", "reasoning":{"effort":"high","context":"current_turn"}, + "parallel_tool_calls":true, "tools":[ {"type":"function","name":"shell","parameters":{"type":"object"}}, {"type":"custom","name":"exec"}, @@ -306,6 +455,160 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String()) require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + + badRec := httptest.NewRecorder() + badCtx, _ := gin.CreateTestContext(badRec) + badCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + badCtx.Request.Header.Set(responsesLiteHeader, "true") + badUpstream := &httpUpstreamRecorder{} + svc.httpUpstream = badUpstream + + result, err = svc.Forward(context.Background(), badCtx, account, []byte(`{"model":"gpt-5.6-terra","tools":[{"type":"function","name":"shell"}],"parallel_tool_calls":"false"}`)) + + require.ErrorContains(t, err, "parallel_tool_calls to be a boolean") + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, badRec.Code) + require.Equal(t, "invalid_request_error", gjson.Get(badRec.Body.String(), "error.type").String()) + require.Equal(t, "parallel_tool_calls", gjson.Get(badRec.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(badRec.Body.String(), "error.message").String(), "parallel_tool_calls to be a boolean") + require.Nil(t, badUpstream.lastReq) + + for _, malformed := range []struct { + body string + wantParam string + }{ + {body: `{"model":"gpt-5.6-terra","tools":{}}`, wantParam: "tools"}, + {body: `{"model":"gpt-5.6-terra","reasoning":[]}`, wantParam: "reasoning"}, + } { + rec := httptest.NewRecorder() + requestCtx, _ := gin.CreateTestContext(rec) + requestCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + requestCtx.Request.Header.Set(responsesLiteHeader, "true") + + result, err = svc.Forward(context.Background(), requestCtx, account, []byte(malformed.body)) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, malformed.wantParam, gjson.Get(rec.Body.String(), "error.param").String()) + } + }) + } +} + +func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(t *testing.T) { + gin.SetMode(gin.TestMode) + + accountCases := []struct { + name string + accountType string + credentials map[string]any + }{ + {name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}}, + {name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}}, + } + parallelCases := []struct { + name string + field string + }{ + {name: "字段缺失"}, + {name: "值为 true", field: `,"parallel_tool_calls":true`}, + {name: "值为 false", field: `,"parallel_tool_calls":false`}, + } + + for _, accountCase := range accountCases { + for _, passthrough := range []bool{false, true} { + mode := "managed" + if passthrough { + mode = "passthrough" + } + for _, parallelCase := range parallelCases { + name := accountCase.name + "/" + mode + "/" + parallelCase.name + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: accountCase.credentials, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "reasoning":{"effort":"high","context":"current_turn"}, + "input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + ` + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody)) + }) + } + } + } +} + +func TestOpenAIGatewayServiceForward_DisablesParallelToolCallsForResponsesLiteAPIKey(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, passthrough := range []bool{false, true} { + name := "managed" + if passthrough { + name = "passthrough" + } + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + c.Request.Header.Set(responsesLiteHeader, "true") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + + "data: [DONE]\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 503, Name: "responses-lite-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + } + body := []byte(`{ + "model":"gpt-5.6-terra","stream":true,"instructions":"test", + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "parallel_tool_calls":true, + "input":[{"type":"message","role":"user","content":"hello"}] + }`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "tools").IsArray()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) }) } } diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index 20affff9cf..f70bdd89ac 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -271,18 +271,56 @@ func openAIResponsesRejectedInputIndex(pattern *regexp.Regexp, param string) (in return 0, false } +// removeOpenAIResponsesRejectedStatusAtIndex drops the status field the +// upstream rejected, and the status of every other input item sharing the +// rejected item's type. +// +// The upstream names one offending index per response, but a replayed +// conversation routinely carries dozens of items of the same type, each with a +// status its schema does not accept. Clearing one index per round trip would +// need one retry per item and exhaust the bounded retry budget long before the +// request could succeed. Items of other types keep their status: the rejection +// only proves that this type has no status field. func removeOpenAIResponsesRejectedStatusAtIndex(body []byte, index int) ([]byte, string, bool, error) { itemPath := fmt.Sprintf("input.%d", index) - if !gjson.GetBytes(body, itemPath).IsObject() { + rejected := gjson.GetBytes(body, itemPath) + if !rejected.IsObject() { return nil, "", false, nil } - statusPath := itemPath + ".status" - if !gjson.GetBytes(body, statusPath).Exists() { + if !gjson.GetBytes(body, itemPath+".status").Exists() { return nil, "", false, nil } - retryBody, err := sjson.DeleteBytes(body, statusPath) - if err != nil { - return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err) + + retryBody := body + cleared := 0 + rejectedType := strings.TrimSpace(rejected.Get("type").String()) + if input := gjson.GetBytes(body, "input"); rejectedType != "" && input.IsArray() { + // Deleting a field never shifts array indexes, so positions read from + // the original body stay valid against the rewritten one. + for itemIndex, item := range input.Array() { + if !item.IsObject() || strings.TrimSpace(item.Get("type").String()) != rejectedType { + continue + } + statusPath := fmt.Sprintf("input.%d.status", itemIndex) + if !gjson.GetBytes(retryBody, statusPath).Exists() { + continue + } + next, err := sjson.DeleteBytes(retryBody, statusPath) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", itemIndex, err) + } + retryBody = next + cleared++ + } + } + if cleared == 0 { + // The rejected item carries no type to match on; fall back to clearing + // just the index the upstream named. + next, err := sjson.DeleteBytes(retryBody, itemPath+".status") + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected status at input[%d]: %w", index, err) + } + retryBody = next } return retryBody, "indexed status parameter rejection", true, nil } diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 96b118050c..9f794b1e57 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -681,3 +682,42 @@ func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response Body: io.NopCloser(strings.NewReader(body)), } } + +// A replayed conversation carries many items of the same type, each with a +// status the upstream schema rejects. One rejection must clear all of them: +// clearing one index per round trip exhausts the bounded retry budget. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) { + input := make([]string, 0, 12) + for i := 0; i < 10; i++ { + input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`) + } + input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`) + body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`) + + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`) + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, reason) + + for i := 0; i < 10; i++ { + require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(), + "every tool_search_output must lose its status in a single retry, index %d did not", i) + require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(), + "unrelated fields must survive") + } + require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(), + "a different item type keeps its status: the rejection only proves this type has none") +} + +// The rejected item may carry no type to match on. +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) { + body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`) + responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`) + + retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String()) + require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists()) +} diff --git a/backend/internal/service/openai_responses_stream_output_items_test.go b/backend/internal/service/openai_responses_stream_output_items_test.go new file mode 100644 index 0000000000..9cfe3f3d78 --- /dev/null +++ b/backend/internal/service/openai_responses_stream_output_items_test.go @@ -0,0 +1,117 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// A terminal event that arrives with an empty output must be rebuilt from the +// items the stream reported, not from delta accumulation. The accumulator +// models only one reasoning and one message, so rebuilding through it collapses +// a multi-item turn into a single fabricated message. +func TestNormalizeResponsesStreamingTerminalOutputPreservesReportedItems(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + + doneItems.Observe([]byte(`{ + "type":"response.output_item.done", + "output_index":0, + "item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}],"encrypted_content":"opaque"} + }`)) + doneItems.Observe([]byte(`{ + "type":"response.output_item.done", + "output_index":1, + "item":{"id":"msg_1","type":"message","status":"completed","phase":"final_answer","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]} + }`)) + + normalized, changed := normalizeResponsesStreamingTerminalOutput( + []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`), + nil, + doneItems, + nil, + ) + require.True(t, changed) + + output := gjson.GetBytes(normalized, "response.output") + require.True(t, output.IsArray()) + require.Len(t, output.Array(), 2, "both reported items must survive") + + require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String()) + require.Equal(t, "rs_1", gjson.GetBytes(normalized, "response.output.0.id").String()) + require.Equal(t, "opaque", gjson.GetBytes(normalized, "response.output.0.encrypted_content").String(), + "fields the gateway does not model must survive verbatim") + + require.Equal(t, "message", gjson.GetBytes(normalized, "response.output.1.type").String()) + require.Equal(t, "msg_1", gjson.GetBytes(normalized, "response.output.1.id").String(), + "the reported id must be reused, not regenerated") + require.Equal(t, "completed", gjson.GetBytes(normalized, "response.output.1.status").String()) + require.Equal(t, "final_answer", gjson.GetBytes(normalized, "response.output.1.phase").String()) + require.Equal(t, "shipped", gjson.GetBytes(normalized, "response.output.1.content.0.text").String()) +} + +// Items are ordered by output_index, not by arrival order. +func TestResponsesStreamOutputItemsOrderByOutputIndex(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"id":"c","type":"message"}}`)) + doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"a","type":"reasoning"}}`)) + + built, ok := doneItems.BuildOutput() + require.True(t, ok) + require.Equal(t, "a", gjson.GetBytes(built, "0.id").String()) + require.Equal(t, "c", gjson.GetBytes(built, "1.id").String()) +} + +// A stream that never reports a done item keeps the previous rebuild path. +func TestNormalizeResponsesStreamingTerminalOutputIgnoresNonDoneEvents(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message"}}`)) + doneItems.Observe([]byte(`{"type":"response.output_text.delta","output_index":0,"delta":"hi"}`)) + require.False(t, doneItems.HasItems()) + + raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`) + normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil) + require.False(t, changed) + require.Equal(t, string(raw), string(normalized)) +} + +// The terminal event can arrive with a non-empty but truncated output: the +// stream reported two items, the terminal carries one, and its id was not the +// one the stream reported. The reported items win. +func TestNormalizeResponsesStreamingTerminalOutputRepairsTruncatedOutput(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":0, + "item":{"id":"rs_real","type":"reasoning","status":"in_progress","summary":[]} + }`)) + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":1, + "item":{"id":"msg_real","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]} + }`)) + + normalized, changed := normalizeResponsesStreamingTerminalOutput([]byte(`{ + "type":"response.completed", + "response":{"status":"completed","output":[{"type":"message","role":"assistant","id":"msg_fabricated","status":"completed","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]}]} + }`), nil, doneItems, nil) + require.True(t, changed) + + require.Len(t, gjson.GetBytes(normalized, "response.output").Array(), 2) + require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String()) + require.Equal(t, "rs_real", gjson.GetBytes(normalized, "response.output.0.id").String()) + require.Equal(t, "msg_real", gjson.GetBytes(normalized, "response.output.1.id").String(), + "the id the stream reported must replace the fabricated one") +} + +// A terminal output that is already complete is never rewritten. +func TestNormalizeResponsesStreamingTerminalOutputLeavesCompleteOutputAlone(t *testing.T) { + doneItems := newResponsesStreamOutputItems() + doneItems.Observe([]byte(`{ + "type":"response.output_item.done","output_index":0, + "item":{"id":"msg_real","type":"message","status":"completed"} + }`)) + + raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","id":"msg_upstream","status":"completed","vendor":"keep"}]}}`) + normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil) + require.False(t, changed) + require.Equal(t, string(raw), string(normalized)) +} diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index cf676645e1..e899e17098 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -497,7 +497,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) { normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformOpenAI, Type: accountType, - }) + }, false) require.NoError(t, err) require.True(t, changed) require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String()) @@ -508,7 +508,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) { normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{ Platform: PlatformGrok, Type: AccountTypeAPIKey, - }) + }, false) require.NoError(t, err) require.False(t, changed) require.Equal(t, string(body), string(normalized)) @@ -587,7 +587,9 @@ func TestSanitizeOpenAIResponsesToolParameterTypes_RewriteCountIndependentOfHits }) // 命中切片扩容是对数级,留出充裕余量;线性写法在这里会是 2000 量级。 - require.Less(t, largeAllocs, smallAllocs+40, + // 干净环境实测 large 约 17 allocs,200 是 10 倍余量,同时容忍 CI 慢 pod 上 + // 包内后台 goroutine(日志/ticker)对进程级 Mallocs 的噪声污染。 + require.Less(t, largeAllocs, 200.0, "分配次数随命中数线性增长,说明退回了逐路径全量重写 (small=%v large=%v)", smallAllocs, largeAllocs) // 同时确认大 body 的结果确实全部修好了。 diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 7405deb419..8f2f2bfcee 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -227,7 +227,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.NotNil(t, secondResult) require.True(t, isOpenAICompatMessagesBridgeContext(secondCtx)) require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) - require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex-tui") } diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 4093ca0b64..b0f486aa9a 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -120,7 +120,7 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co // Client disconnected: do NOT fail over to another account and do NOT evict // this one — the upstream never had a chance to exhibit a fault. - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded)) { return err } @@ -129,6 +129,12 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co scheduleOllamaCloudUsageActivity(s.deferredService, account) } + // 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。 + var pluginErr *PluginTransportError + if errors.As(err, &pluginErr) && pluginErr.RequestSent { + return err + } + if classifyOpenAITransportError(err).Persistent { s.tempUnscheduleOpenAITransportError(ctx, account, safeErr) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b4a8752347..8dbb5b1abb 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -86,6 +86,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( // A handler may reuse the same gin context across account failover attempts. // Never let an OAuth attempt's response aliases leak into the next account. setCodexToolNameReverse(c, nil) + if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { + return err + } if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } @@ -112,7 +115,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - forceHTTPBridge := account.Platform == PlatformGrok + forceHTTPBridge := account.Platform == PlatformGrok || + (s.pluginManager != nil && s.pluginManager.ShouldRouteOpenAIOAuth(account)) modeRouterV2Enabled := s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled ingressMode := OpenAIWSIngressModeCtxPool if modeRouterV2Enabled && !forceHTTPBridge { @@ -180,15 +184,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) type openAIWSClientPayload struct { - payloadRaw []byte - rawForHash []byte - promptCacheKey string - previousResponseID string - originalModel string - imageBillingModel string - imageSizeTier string - imageInputSize string - payloadBytes int + payloadRaw []byte + accountIdentitySourceRaw []byte + rawForHash []byte + promptCacheKey string + previousResponseID string + originalModel string + imageBillingModel string + imageSizeTier string + imageInputSize string + payloadBytes int } ingressSessionOriginalModel := "" @@ -257,7 +262,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = capped } } - if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account); compatibilityErr != nil { + responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) } else if compatibilityChanged { normalized = compatibilityBody @@ -307,8 +313,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(normalized) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized) + accountIdentitySourceRaw := append([]byte(nil), normalized...) + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(normalized, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + normalized = accountScopedPayload + } + if responsesLite { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account) if liteErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError( coderws.StatusPolicyViolation, @@ -449,15 +463,16 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ingressSessionOriginalModel = originalModel return openAIWSClientPayload{ - payloadRaw: normalized, - rawForHash: trimmed, - promptCacheKey: promptCacheKey, - previousResponseID: previousResponseID, - originalModel: originalModel, - imageBillingModel: imageBillingModel, - imageSizeTier: imageSizeTier, - imageInputSize: imageInputSize, - payloadBytes: len(normalized), + payloadRaw: normalized, + accountIdentitySourceRaw: accountIdentitySourceRaw, + rawForHash: trimmed, + promptCacheKey: promptCacheKey, + previousResponseID: previousResponseID, + originalModel: originalModel, + imageBillingModel: imageBillingModel, + imageSizeTier: imageSizeTier, + imageInputSize: imageInputSize, + payloadBytes: len(normalized), }, nil } @@ -644,7 +659,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( var failoverErr *UpstreamFailoverError if turn > 1 && errors.As(bridgeErr, &failoverErr) && failoverErr != nil { retryPayload, retrySafe, retryPayloadErr := buildOpenAIWSCurrentTurnRetryPayload( - bridgePayloadRaw, + currentBridgePayload.accountIdentitySourceRaw, turnAccountFailoverInput, turnAccountFailoverInputExists, currentBridgePayload.originalModel, @@ -1162,7 +1177,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), - ServiceTier: extractOpenAIServiceTierFromBody(payload), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(payload)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), Stream: reqStream, OpenAIWSMode: true, 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 868da4776d..b05a2efd10 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -732,7 +732,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","sequence":900719925474099312345}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"input":"draw a cat","parallel_tool_calls":true,"sequence":900719925474099312345}`)) cancelWrite() require.NoError(t, err) @@ -750,6 +750,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge "stream":false, "previous_response_id":"resp_codex_image_bridge", "reasoning":{"effort":"high"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[ @@ -787,11 +788,23 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, coderws.MessageText, msgType) require.Equal(t, "resp_codex_image_function", gjson.GetBytes(message, "response.id").String()) - _ = clientConn.Close(coderws.StatusNormalClosure, "done") + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "parallel_tool_calls":"false", + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, + "tools":[{"type":"function","name":"shell"}] + }`)) + cancelWrite() + require.NoError(t, err) select { case serverErr := <-serverErrCh: - require.NoError(t, serverErr) + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, serverErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Contains(t, closeErr.Reason(), "parallel_tool_calls to be a boolean") case <-time.After(5 * time.Second): t.Fatal("等待 ingress websocket 结束超时") } @@ -803,6 +816,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "auto", gjson.Get(nonLitePayload, "tool_choice").String()) require.Contains(t, gjson.Get(nonLitePayload, "instructions").String(), "image_generation") require.False(t, gjson.Get(nonLitePayload, "reasoning.context").Exists()) + require.True(t, gjson.Get(nonLitePayload, "parallel_tool_calls").Bool()) require.Equal(t, "900719925474099312345", gjson.Get(nonLitePayload, "sequence").Raw) litePayload := requestToJSONString(captureConn.writes[1]) @@ -816,6 +830,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String()) require.Equal(t, "high", gjson.Get(litePayload, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(litePayload, "reasoning.context").String()) + require.True(t, gjson.Get(litePayload, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(litePayload, "parallel_tool_calls").Bool()) functionPayload := requestToJSONString(captureConn.writes[2]) require.True(t, gjson.Get(functionPayload, `tools.#(name=="image_gen.imagegen")`).Exists()) @@ -1201,6 +1217,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade "stream":false, "prompt_cache_key":"pcache_passthrough", "reasoning":{"effort":"medium","context":"current_turn"}, + "parallel_tool_calls":true, "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}, "tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}], "input":[{"type":"message","role":"user","content":"hello"}], @@ -1225,7 +1242,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade t.Fatal("等待 passthrough websocket 结束超时") } - require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id")) require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader)) require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader)) require.Len(t, upstreamConn.writes, 1) @@ -1236,6 +1253,8 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String()) require.Equal(t, "medium", gjson.Get(forwarded, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.Get(forwarded, "reasoning.context").String()) + require.True(t, gjson.Get(forwarded, "parallel_tool_calls").Exists()) + require.False(t, gjson.Get(forwarded, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) { @@ -1342,7 +1361,11 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe }() writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} + }`)) cancelWrite() require.NoError(t, err) @@ -1383,6 +1406,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe } require.NotNil(t, upstream.lastReq, "http_bridge 模式应调用 HTTP 上游") + require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader)) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) { diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 89d0dfc18c..94738221a2 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -121,10 +121,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if account != nil && account.UsesOpenAICodexProtocol() { apiKeyID := getAPIKeyIDFromContext(c) if sessionResolution.SessionID != "" { - headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID)) + headers.Set("session_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.SessionID)) } if sessionResolution.ConversationID != "" { - headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID)) + headers.Set("conversation_id", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionResolution.ConversationID)) } } else { if sessionResolution.SessionID != "" { @@ -140,6 +140,7 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } + applyCodexAccountIdentityHeaders(headers, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) applyStagedCodexFingerprintHeaders(c, account, headers) if account != nil && account.UsesOpenAICodexProtocol() { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 24f23f0d2b..e775104fa2 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -430,7 +430,7 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersPreservesCodexIdentity(t *test require.Empty(t, headers.Get("X-Test")) } -func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessionIdentity(t *testing.T) { +func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesNamespacedClientSessionIdentity(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -465,10 +465,10 @@ func TestOpenAIGatewayService_BuildOpenAIWSHeadersDeviceModePreservesClientSessi require.NoError(t, err) require.Equal(t, ids.installationID, headers.Get("x-codex-installation-id")) require.NotEqual(t, "client-installation", headers.Get("x-codex-installation-id")) - require.Equal(t, "client-window", headers.Get("x-codex-window-id")) - require.Equal(t, "client-session", headers.Get("session-id")) - require.Equal(t, "client-thread", headers.Get("thread-id")) - require.Equal(t, "client-request", headers.Get("x-client-request-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "window", "client-window"), headers.Get("x-codex-window-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), headers.Get("session-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), headers.Get("thread-id")) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "request", "client-request"), headers.Get("x-client-request-id")) } func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) { @@ -798,10 +798,10 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace") require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta")) require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features")) - // OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离, + // OAuth 账号的 session_id/conversation_id 应同时按 API key 和上游账号隔离, // 测试中未设置 api_key 到 context,apiKeyID=0。 - require.Equal(t, isolateOpenAISessionID(0, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) - require.Equal(t, isolateOpenAISessionID(0, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "sess-oauth-1"), captureDialer.lastHeaders.Get("session_id")) + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id")) } func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) { @@ -1023,8 +1023,8 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK require.NotNil(t, result) require.Equal(t, "resp_prompt_cache_key", result.RequestID) - // OAuth 账号的 session_id 应被 isolateOpenAISessionID 隔离(apiKeyID=0,未在 context 设置)。 - require.Equal(t, isolateOpenAISessionID(0, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) + // OAuth 账号的 session_id 应同时按 API key 和上游账号隔离(apiKeyID=0)。 + require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "pcache_123"), captureDialer.lastHeaders.Get("session_id")) require.Empty(t, captureDialer.lastHeaders.Get("conversation_id")) require.NotNil(t, captureConn.lastWrite) require.True(t, gjson.Get(requestToJSONString(captureConn.lastWrite), "stream").Exists()) diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 4ecd73d09a..7702ebe797 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -22,6 +22,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( c *gin.Context, account *Account, reqBody map[string]any, + clientPromptCacheKey string, token string, decision OpenAIWSProtocolDecision, isCodexCLI bool, @@ -72,7 +73,12 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( applyStagedCodexFingerprintClientMetadata(c, account, payload) previousResponseID := openAIWSPayloadString(payload, "previous_response_id") previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID) - promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key") + promptCacheKey := strings.TrimSpace(clientPromptCacheKey) + if promptCacheKey == "" { + // Fingerprint convergence may inject a default key when the client did + // not send one; retain that fallback without replacing an explicit raw key. + promptCacheKey = openAIWSPayloadString(payload, "prompt_cache_key") + } _, hasTools := payload["tools"] debugEnabled := isOpenAIWSModeDebugEnabled() payloadBytes := -1 @@ -773,9 +779,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), ImageCount: imageCounter.Count(), ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: extractOpenAIServiceTier(reqBody), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go new file mode 100644 index 0000000000..7cfd7f9928 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// HTTP POST /v1/responses → forwardOpenAIWSV2 共用 stream/non-stream 的 +// OpenAIForwardResult:上游 response.completed.service_tier 必须覆盖请求 +// fast/priority,不能只读 reqBody。 +func TestForwardOpenAIWSV2_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + requestTier string + stream bool + }{ + {name: "priority_nonstream", requestTier: "priority", stream: false}, + {name: "fast_stream", requestTier: "fast", stream: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_tier_v2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5882, + Name: "openai-ws-v2-tier", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + body := []byte(fmt.Sprintf( + `{"model":"gpt-5.5","stream":%t,"service_tier":%q,"input":[{"type":"input_text","text":"hi"}]}`, + tc.stream, tc.requestTier, + )) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.OpenAIWSMode, "must take HTTP POST → forwardOpenAIWSV2, not HTTP fallback") + require.Equal(t, tc.stream, result.Stream) + require.Equal(t, "resp_tier_v2", result.RequestID) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) + require.Equal(t, "priority", captureConn.lastWrite["service_tier"], + "outbound WS payload still carries the requested Fast tier") + }) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index d13a370d42..a6c0528045 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -350,6 +350,15 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( LoweredTools: loweredTools, }) } + if account.Platform != PlatformGrok && isOpenAIResponsesLiteWebSocketPayload(payload) { + liteBody, liteChanged, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account) + if liteErr != nil { + return nil, fmt.Errorf("normalize responses Lite payload: %w", liteErr) + } + if liteChanged { + body = liteBody + } + } buildUpstreamRequest := func(requestBody []byte) (*http.Request, error) { upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) @@ -408,7 +417,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if buildErr != nil { return nil, buildErr } - resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { if turn == 1 { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) @@ -514,7 +523,8 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( UpstreamModel: mappedModel, UpstreamResponseModel: responseModelObserver.Model(), UpstreamResponseModelConflict: responseModelObserver.Conflict(), - ServiceTier: extractOpenAIServiceTierFromBody(body), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(body)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), Stream: reqStream, OpenAIWSMode: true, diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go index 074c08f8ce..1468892bbe 100644 --- a/backend/internal/service/openai_ws_http_bridge_resume_test.go +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -150,11 +150,13 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t account := &Account{ ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, - Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Credentials: map[string]any{"chatgpt_account_id": "account-a", "chatgpt_user_id": "user-a"}, } nextAccount := *account nextAccount.ID = 130 nextAccount.Name = "replacement" + nextAccount.Credentials = map[string]any{"chatgpt_account_id": "account-b", "chatgpt_user_id": "user-b"} serverErrCh := make(chan error, 1) failoverCh := make(chan []byte, 1) @@ -212,7 +214,7 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","prompt_cache_key":"client-session","client_metadata":{"session_id":"client-session","thread_id":"client-thread"},"input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) cancel() require.NoError(t, err) @@ -237,6 +239,8 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t require.Contains(t, input.Raw, "second") require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`)) require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`)) + require.Equal(t, "client-session", gjson.GetBytes(retryPayload, "client_metadata.session_id").String()) + require.Equal(t, "client-thread", gjson.GetBytes(retryPayload, "client_metadata.thread_id").String()) case <-time.After(5 * time.Second): t.Fatal("timed out waiting for current-turn failover") } @@ -249,7 +253,11 @@ func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t } require.Len(t, upstream.bodies, 3) require.Contains(t, string(upstream.bodies[0]), "first") + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[1], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(account, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[1], "client_metadata.thread_id").String()) require.NotContains(t, string(upstream.bodies[2]), "previous_response_id") require.Contains(t, string(upstream.bodies[2]), "second") + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "session", "client-session"), gjson.GetBytes(upstream.bodies[2], "client_metadata.session_id").String()) + require.Equal(t, scopeCodexAccountIdentityValue(&nextAccount, 0, "thread", "client-thread"), gjson.GetBytes(upstream.bodies[2], "client_metadata.thread_id").String()) require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader)) } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 222f0ed5eb..bbff773d76 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -60,6 +60,82 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String()) } +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + // proxyOpenAIWSHTTPBridgeTurn 是 client WS→HTTP bridge,本身不 canonicalize + // fast→priority;生产入口的归一化在 openai_ws_forwarder_ingress.go 的 fast + // policy。本测试只覆盖局部 observer:canonical 请求 priority 被上游 + // response.completed service_tier=default 覆盖。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5881, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"priority","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "priority", gjson.GetBytes(upstream.lastBody, "service_tier").String()) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) +} + +func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultWinsOverFastAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 客户端别名 fast 同样被上游回显的 default 覆盖:局部 observer 的 + // ServiceTier() 是唯一计费依据,绝不回退到请求侧 fast。 + sse := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_tier2","model":"gpt-5.5","status":"completed","service_tier":"default","usage":{"input_tokens":1,"output_tokens":1}}}`, + ``, + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ID: 5882, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + payload := []byte(`{"type":"response.create","model":"gpt-5.5","stream":true,"service_tier":"fast","input":"hi"}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "test-token", payload, len(payload), + "gpt-5.5", "", "", "", "", 1, + func([]byte) error { return nil }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier, + "local observer's upstream-echoed default must win over the fast alias") +} + func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1179,7 +1255,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { Concurrency: 1, Status: StatusActive, } - payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi"}`) + payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","parallel_tool_calls":true}`) type bridgeResult struct { result *OpenAIForwardResult @@ -1265,6 +1341,8 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) { require.False(t, gjson.GetBytes(upstream.lastBody, "type").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, "generate").Exists()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) } func TestProxyOpenAIWSHTTPBridgeTurnForGrokDefaultsEmptyModelTo45(t *testing.T) { diff --git a/backend/internal/service/openai_ws_protocol_resolver_test.go b/backend/internal/service/openai_ws_protocol_resolver_test.go index 12e047f100..32716a2c70 100644 --- a/backend/internal/service/openai_ws_protocol_resolver_test.go +++ b/backend/internal/service/openai_ws_protocol_resolver_test.go @@ -87,6 +87,15 @@ func TestOpenAIWSProtocolResolver_Resolve(t *testing.T) { require.Equal(t, "account_force_http", decision.Reason) }) + t.Run("全局强制HTTP无需启用mode router", func(t *testing.T) { + cfg := *baseCfg + cfg.Gateway.OpenAIWS.ForceHTTP = true + + decision := NewOpenAIWSProtocolResolver(&cfg).Resolve(openAIOAuthEnabled) + require.Equal(t, OpenAIUpstreamTransportHTTPSSE, decision.Transport) + require.Equal(t, "global_force_http", decision.Reason) + }) + t.Run("全局关闭保持HTTP", func(t *testing.T) { cfg := *baseCfg cfg.Gateway.OpenAIWS.Enabled = false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 67c26f3e96..e28223e091 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -33,9 +33,12 @@ type Usage struct { } type RelayResult struct { - RequestModel string - ResponseModel string - ResponseModelConflict bool + RequestModel string + ResponseModel string + ResponseModelConflict bool + // ResponseServiceTier is the raw service_tier declared by the last terminal + // response event; "" when the upstream never declared one. + ResponseServiceTier string Usage Usage RequestID string TerminalEventType string @@ -50,6 +53,7 @@ type RelayTurnResult struct { RequestModel string ResponseModel string ResponseModelConflict bool + ResponseServiceTier string Usage Usage RequestID string TerminalEventType string @@ -96,19 +100,20 @@ type RelayTraceEvent struct { } type relayState struct { - usage Usage - turnUsage Usage - requestModelMu sync.RWMutex - requestModel string - pendingTurnStart atomic.Pointer[time.Time] - lastResponseID string - lastResponseModel string - responseConflict bool - terminalEventType string - firstTokenMs *int - turnTimingByID map[string]*relayTurnTiming - activeTurn *relayTurnTiming - pendingBareError *observedUpstreamEvent + usage Usage + turnUsage Usage + requestModelMu sync.RWMutex + requestModel string + pendingTurnStart atomic.Pointer[time.Time] + lastResponseID string + lastResponseModel string + lastResponseServiceTier string + responseConflict bool + terminalEventType string + firstTokenMs *int + turnTimingByID map[string]*relayTurnTiming + activeTurn *relayTurnTiming + pendingBareError *observedUpstreamEvent } type relayExitSignal struct { @@ -119,15 +124,16 @@ type relayExitSignal struct { } type observedUpstreamEvent struct { - terminal bool - eventType string - responseID string - usage Usage - startedAt time.Time - responseModel string - responseConflict bool - duration time.Duration - firstToken *int + terminal bool + eventType string + responseID string + usage Usage + startedAt time.Time + responseModel string + responseConflict bool + responseServiceTier string + duration time.Duration + firstToken *int } type relayTurnTiming struct { @@ -136,6 +142,9 @@ type relayTurnTiming struct { firstResponseModel string terminalResponseModel string responseModelConflict bool + // terminalResponseServiceTier is only taken from terminal events: earlier + // events echo the requested tier, not the one the upstream actually used. + terminalResponseServiceTier string } func Relay( @@ -757,6 +766,7 @@ func observeUpstreamMessage( if !isTerminalEvent(eventType) { return observed } + observeRelayTurnResponseServiceTier(turnTiming, firstRelayResponseServiceTier(message)) state.terminalEventType = eventType if eventType == "error" { // Some Responses servers emit error immediately before response.failed. @@ -815,8 +825,10 @@ func finalizeObservedRelayTerminal(state *relayState, observed observedUpstreamE if turnTiming, ok := openAIWSRelayDeleteTurnTiming(state, responseID); ok { observed.responseModel = relayTurnResponseModel(&turnTiming) observed.responseConflict = turnTiming.responseModelConflict + observed.responseServiceTier = turnTiming.terminalResponseServiceTier state.lastResponseModel = observed.responseModel state.responseConflict = observed.responseConflict + state.lastResponseServiceTier = observed.responseServiceTier duration := now.Sub(turnTiming.startAt) if duration < 0 { duration = 0 @@ -852,6 +864,7 @@ func emitTurnComplete( RequestModel: requestModel, ResponseModel: observed.responseModel, ResponseModelConflict: observed.responseConflict, + ResponseServiceTier: observed.responseServiceTier, Usage: observed.usage, RequestID: responseID, TerminalEventType: observed.eventType, @@ -908,6 +921,31 @@ func relayTurnResponseModel(turn *relayTurnTiming) string { return turn.firstResponseModel } +func firstRelayResponseServiceTier(message []byte) string { + if len(message) == 0 { + return "" + } + values := gjson.GetManyBytes(message, "response.service_tier", "service_tier") + for _, value := range values { + if value.Type != gjson.String { + continue + } + if tier := strings.TrimSpace(value.String()); tier != "" { + return tier + } + } + return "" +} + +func observeRelayTurnResponseServiceTier(turn *relayTurnTiming, tier string) { + if turn == nil { + return + } + if tier = strings.TrimSpace(tier); tier != "" { + turn.terminalResponseServiceTier = tier + } +} + func openAIWSRelayGetOrInitTurnTiming(state *relayState, responseID string, now time.Time) *relayTurnTiming { if state == nil { return nil @@ -1165,6 +1203,7 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration result.RequestModel = state.currentRequestModel() result.ResponseModel = state.lastResponseModel result.ResponseModelConflict = state.responseConflict + result.ResponseServiceTier = state.lastResponseServiceTier result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType 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 cd02c3858f..80fef47d44 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -744,3 +744,54 @@ func TestObserveUpstreamMessage_ResponseIDFallbackPolicy(t *testing.T) { require.True(t, observed.terminal) require.Equal(t, "resp_fallback", observed.responseID) } + +func TestObserveUpstreamMessage_ResponseServiceTierOnlyFromTerminalEvents(t *testing.T) { + t.Parallel() + + state := &relayState{requestModel: "gpt-5.6-sol"} + startAt := time.Unix(0, 0) + now := startAt + nowFn := func() time.Time { + now = now.Add(5 * time.Millisecond) + return now + } + + created := observeUpstreamMessage( + state, + []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"priority"}}`), + startAt, + nowFn, + nil, + ) + require.False(t, created.terminal) + require.Equal(t, "", created.responseServiceTier) + + completed := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default","usage":{"input_tokens":1,"output_tokens":2}}}`), + startAt, + nowFn, + nil, + ) + require.True(t, completed.terminal) + require.Equal(t, "default", completed.responseServiceTier, "the echoed priority tier must lose to the terminal declaration") + + var turn RelayTurnResult + emitTurnComplete(func(result RelayTurnResult) { turn = result }, state, completed) + require.Equal(t, "default", turn.ResponseServiceTier) + + var result RelayResult + enrichResult(&result, state, now.Sub(startAt)) + require.Equal(t, "default", result.ResponseServiceTier) + + // A later turn without any declaration must not inherit the previous one. + observeUpstreamMessage(state, []byte(`{"type":"response.created","response":{"id":"resp_2","model":"gpt-5.6-sol"}}`), startAt, nowFn, nil) + second := observeUpstreamMessage( + state, + []byte(`{"type":"response.completed","response":{"id":"resp_2","model":"gpt-5.6-sol","usage":{"input_tokens":3,"output_tokens":4}}}`), + startAt, + nowFn, + nil, + ) + require.Equal(t, "", second.responseServiceTier) +} diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 27cd4b44c3..60e7b41416 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -679,8 +679,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if err := validateOpenAIWSBearerToken(account, token); err != nil { return err } - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { - liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage) + if isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) { + liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(firstClientMessage, account) if liteErr != nil { return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -693,6 +693,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String()) + promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) logOpenAIWSV2Passthrough( "relay_start account_id=%d model=%s previous_response_id=%s first_message_type=%s first_message_bytes=%d", account.ID, @@ -734,7 +735,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil { + firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized @@ -749,6 +751,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = aliasedBody } } + accountScopedFirst, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(firstClientMessage, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + firstClientMessage = accountScopedFirst + } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) if policyErr != nil { @@ -789,8 +798,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel) _, initialUpstreamModel := usageMeta.turnModels(initialRequestModel) SetOpsUpstreamModel(c, initialUpstreamModel) - promptCacheKey := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "prompt_cache_key").String()) - wsURL, err := s.buildOpenAIResponsesWSURL(account) if err != nil { return fmt.Errorf("build ws url: %w", err) @@ -978,8 +985,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } }() } + responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized @@ -995,9 +1003,18 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( payload = aliasedBody } } + if isResponseCreate || eventType == "session.update" { + accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) + if scopeErr != nil { + return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) + } + if accountScoped { + payload = accountScopedPayload + } + } if isResponseCreate { - if account.IsOpenAIOAuthLike() && isOpenAIResponsesLiteWebSocketPayload(payload) { - litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload) + if responsesLite { + litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account) if liteErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr) } @@ -1181,6 +1198,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), UpstreamResponseModel: turn.ResponseModel, UpstreamResponseModelConflict: turn.ResponseModelConflict, + UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(turn.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, @@ -1306,6 +1324,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), UpstreamResponseModel: relayResult.ResponseModel, UpstreamResponseModelConflict: relayResult.ResponseModelConflict, + UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(relayResult.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index de3d5fdf27..ab7d704b04 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -190,13 +190,18 @@ func startPassthroughLifecycleServer( } func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn { + t.Helper() + return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`) +} + +func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn { t.Helper() dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) cancelDial() require.NoError(t, err) writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload)) cancelWrite() require.NoError(t, err) return clientConn @@ -221,6 +226,35 @@ func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughCo } } +func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount()) + defer server.Close() + clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{ + "type":"response.create","model":"gpt-5.1","stream":false, + "parallel_tool_calls":true, + "client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"} + }`) + defer func() { _ = clientConn.CloseNow() }() + + upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second) + require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody)) + + event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case <-serverErr: + case <-time.After(3 * time.Second): + t.Fatal("Lite 首帧测试等待 passthrough 退出超时") + } +} + func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) { clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)} clientFrameConn.markTurnCompleted() diff --git a/backend/internal/service/ops_metrics_collector.go b/backend/internal/service/ops_metrics_collector.go index d4f5c32929..4fc9576044 100644 --- a/backend/internal/service/ops_metrics_collector.go +++ b/backend/internal/service/ops_metrics_collector.go @@ -601,24 +601,11 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle sampleAt := time.Now().UTC() - // Prefer cgroup (container) metrics when available. + // CPU: prefer cgroup (container) metrics, falling back to host metrics when + // cgroup CPU accounting is unavailable. if cpuPct := c.tryCgroupCPUPercent(sampleAt); cpuPct != nil { out.cpuUsagePercent = cpuPct } - - cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() - if cgroupOK { - usedMB := int64(cgroupUsed / bytesPerMB) - out.memoryUsedMB = &usedMB - if cgroupTotal > 0 { - totalMB := int64(cgroupTotal / bytesPerMB) - out.memoryTotalMB = &totalMB - pct := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) - out.memoryUsagePercent = &pct - } - } - - // Fallback to host metrics if cgroup metrics are unavailable (or incomplete). if out.cpuUsagePercent == nil { if cpuPercents, err := cpu.PercentWithContext(ctx, 0, false); err == nil && len(cpuPercents) > 0 { v := roundTo1DP(cpuPercents[0]) @@ -626,32 +613,60 @@ func (c *OpsMetricsCollector) collectSystemStats(ctx context.Context) (*opsColle } } - // If total memory isn't available from cgroup (e.g. memory.max = "max"), fill total from host. - if out.memoryUsedMB == nil || out.memoryTotalMB == nil || out.memoryUsagePercent == nil { - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil && vm != nil { - if out.memoryUsedMB == nil { - usedMB := int64(vm.Used / bytesPerMB) - out.memoryUsedMB = &usedMB - } - if out.memoryTotalMB == nil { - totalMB := int64(vm.Total / bytesPerMB) - out.memoryTotalMB = &totalMB - } - if out.memoryUsagePercent == nil { - if out.memoryUsedMB != nil && out.memoryTotalMB != nil && *out.memoryTotalMB > 0 { - pct := roundTo1DP(float64(*out.memoryUsedMB) / float64(*out.memoryTotalMB) * 100) - out.memoryUsagePercent = &pct - } else { - pct := roundTo1DP(vm.UsedPercent) - out.memoryUsagePercent = &pct - } - } + // Memory: prefer cgroup (container) metrics, but only when the cgroup exposes + // BOTH a current usage and a concrete limit (memory.max != "max"). When the + // limit is missing or the cgroup data is otherwise incomplete, fall back + // ENTIRELY to host metrics. Never mix a container "used" with a host "total": + // doing so reports a misleadingly tiny percentage (e.g. 60MB / 23GB ~= 0.3%). + cgroupUsed, cgroupTotal, cgroupOK := readCgroupMemoryBytes() + var host *mem.VirtualMemoryStat + if !cgroupOK || cgroupTotal == 0 { + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + host = vm } } + out.memoryUsedMB, out.memoryTotalMB, out.memoryUsagePercent = resolveMemoryStats(cgroupUsed, cgroupTotal, cgroupOK, host) return out, nil } +// resolveMemoryStats picks a single, self-consistent (used, total, percent) +// memory trio from either cgroup (container) or host metrics — never a mix of +// the two. +// +// cgroup metrics are preferred, but only when the cgroup reports BOTH a current +// usage and a concrete limit (i.e. memory.max is a number, not "max", so +// cgroupTotal > 0). If the limit is absent or the cgroup data is otherwise +// incomplete, all three values fall back to the host reading. This avoids the +// classic pitfall of dividing a container "used" by a host "total", which +// wildly understates memory usage (e.g. 60MB / 23GB ~= 0.3%). +func resolveMemoryStats(cgroupUsed, cgroupTotal uint64, cgroupOK bool, host *mem.VirtualMemoryStat) (usedMB *int64, totalMB *int64, usagePercent *float64) { + if cgroupOK && cgroupTotal > 0 { + u := int64(cgroupUsed / bytesPerMB) + t := int64(cgroupTotal / bytesPerMB) + p := roundTo1DP(float64(cgroupUsed) / float64(cgroupTotal) * 100) + return &u, &t, &p + } + + if host == nil { + return nil, nil, nil + } + + u := int64(host.Used / bytesPerMB) + usedMB = &u + if host.Total > 0 { + t := int64(host.Total / bytesPerMB) + totalMB = &t + p := roundTo1DP(float64(host.Used) / float64(host.Total) * 100) + usagePercent = &p + } else { + // Degenerate: host reported no total. Preserve gopsutil's own percentage. + p := roundTo1DP(host.UsedPercent) + usagePercent = &p + } + return usedMB, totalMB, usagePercent +} + func (c *OpsMetricsCollector) tryCgroupCPUPercent(now time.Time) *float64 { usageNanos, ok := readCgroupCPUUsageNanos() if !ok { diff --git a/backend/internal/service/ops_metrics_collector_memory_test.go b/backend/internal/service/ops_metrics_collector_memory_test.go new file mode 100644 index 0000000000..0a1e8873a8 --- /dev/null +++ b/backend/internal/service/ops_metrics_collector_memory_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + + "github.com/shirou/gopsutil/v4/mem" + "github.com/stretchr/testify/require" +) + +const ( + testMiB = 1024 * 1024 + testGiB = 1024 * testMiB +) + +// TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost is the core +// regression: in Docker + cgroup v2 with NO memory limit, memory.current is a +// small container number while memory.max = "max" (so cgroupTotal == 0). The +// old code reported the container "used" against the host "total", producing a +// misleadingly tiny percentage (~0.3%). The fix must fall back ENTIRELY to host +// metrics instead of mixing the two sources. +func TestResolveMemoryStatsCgroupUsageButUnlimitedFallsBackToHost(t *testing.T) { + const cgroupUsed = uint64(64573440) // memory.current, ~61 MiB + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(cgroupUsed, 0 /* memory.max = "max" */, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + // Everything must come from the host, not from the cgroup container value. + require.Equal(t, int64(16*1024), *usedMB, "used must be host used, not container used") + require.Equal(t, int64(24*1024), *totalMB, "total must be host total") + require.InDelta(t, 66.7, *pct, 0.05, "percent must be host-derived, not container/host mix") + + // Guard against the specific bug: container used (~61 MiB) vs host total (~0.3%). + require.NotEqual(t, int64(cgroupUsed/testMiB), *usedMB, "must not report the container used value") + require.Greater(t, *pct, 1.0, "percent must not collapse to the ~0.3%% mixed value") +} + +// TestResolveMemoryStatsExplicitContainerLimitUsesCgroup covers the case where +// a real container memory limit is set: memory.current = 512 MiB and +// memory.max = 2 GiB must yield ~25% entirely from cgroup, ignoring the host. +func TestResolveMemoryStatsExplicitContainerLimitUsesCgroup(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, // deliberately different; must be ignored + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(512*testMiB, 2*testGiB, true, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + + require.Equal(t, int64(512), *usedMB) + require.Equal(t, int64(2048), *totalMB) + require.InDelta(t, 25.0, *pct, 0.05) +} + +// TestResolveMemoryStatsNoCgroupUsesHost covers bare-metal / no-cgroup hosts: +// all three values come from the host reading. +func TestResolveMemoryStatsNoCgroupUsesHost(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 16 * testGiB, + Total: 24 * testGiB, + UsedPercent: 66.7, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.NotNil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(16*1024), *usedMB) + require.Equal(t, int64(24*1024), *totalMB) + require.InDelta(t, 66.7, *pct, 0.05) +} + +// TestResolveMemoryStatsNoDataReturnsNil: when neither cgroup nor host data is +// available, all outputs are nil (best-effort, nothing persisted). +func TestResolveMemoryStatsNoDataReturnsNil(t *testing.T) { + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, nil) + require.Nil(t, usedMB) + require.Nil(t, totalMB) + require.Nil(t, pct) +} + +// TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent: degenerate host +// reading with no total still yields a used value and gopsutil's own percentage. +func TestResolveMemoryStatsHostWithoutTotalKeepsGopsutilPercent(t *testing.T) { + host := &mem.VirtualMemoryStat{ + Used: 8 * testGiB, + Total: 0, + UsedPercent: 42.5, + } + + usedMB, totalMB, pct := resolveMemoryStats(0, 0, false, host) + + require.NotNil(t, usedMB) + require.Nil(t, totalMB) + require.NotNil(t, pct) + require.Equal(t, int64(8*1024), *usedMB) + require.InDelta(t, 42.5, *pct, 0.05) +} diff --git a/backend/internal/service/plugin_compatibility.go b/backend/internal/service/plugin_compatibility.go new file mode 100644 index 0000000000..02c3ba3444 --- /dev/null +++ b/backend/internal/service/plugin_compatibility.go @@ -0,0 +1,104 @@ +package service + +import ( + "fmt" + "strings" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "golang.org/x/mod/semver" +) + +type PluginHostInfo struct { + Version string + BuildType string +} + +func EvaluatePluginCompatibility(manifest PluginManifest, host PluginHostInfo) PluginCompatibility { + result := PluginCompatibility{ + CurrentSub2API: host.Version, + RequiredSub2API: manifest.Requires.Sub2API, + RecommendedSub2API: manifest.Requires.RecommendedSub2APIVersion, + PluginProtocol: manifest.Requires.PluginProtocol, + TransportAPI: manifest.Requires.TransportAPI, + UIBridge: manifest.Requires.UIBridge, + } + if manifest.Requires.PluginProtocol != pluginv1.ProtocolVersion || + manifest.Requires.TransportAPI != pluginv1.TransportAPIVersion || + manifest.Requires.UIBridge != pluginv1.UIBridgeVersion { + result.Status = "incompatible" + result.Message = "插件协议版本与当前 Sub2API 不兼容" + return result + } + if !matchesSemverRange(host.Version, manifest.Requires.Sub2API) { + result.Status = "incompatible" + result.Message = fmt.Sprintf("当前 Sub2API %s 不满足插件要求 %s", host.Version, manifest.Requires.Sub2API) + return result + } + result.Compatible = true + for _, tested := range manifest.Requires.TestedSub2APIVersions { + if normalizeSemver(tested) == normalizeSemver(host.Version) { + result.Tested = true + break + } + } + if result.Tested { + result.Status = "compatible" + result.Message = "当前 Sub2API 版本已由插件声明测试" + } else { + result.Status = "untested" + result.Message = "版本范围兼容,但插件未声明已测试当前 Sub2API 版本" + } + return result +} + +func normalizeSemver(version string) string { + v := strings.TrimSpace(version) + if v == "" { + return "" + } + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return "" + } + return v +} + +func matchesSemverRange(version, expression string) bool { + v := normalizeSemver(version) + if v == "" { + return false + } + tokens := strings.Fields(strings.ReplaceAll(expression, ",", " ")) + if len(tokens) == 0 { + return false + } + for _, token := range tokens { + op := "=" + raw := token + for _, candidate := range []string{">=", "<=", ">", "<", "="} { + if strings.HasPrefix(token, candidate) { + op = candidate + raw = strings.TrimSpace(strings.TrimPrefix(token, candidate)) + break + } + } + bound := normalizeSemver(raw) + if bound == "" { + return false + } + comparison := semver.Compare(v, bound) + matched := map[string]bool{ + ">=": comparison >= 0, + "<=": comparison <= 0, + ">": comparison > 0, + "<": comparison < 0, + "=": comparison == 0, + }[op] + if !matched { + return false + } + } + return true +} diff --git a/backend/internal/service/plugin_compatibility_test.go b/backend/internal/service/plugin_compatibility_test.go new file mode 100644 index 0000000000..f819c41d5e --- /dev/null +++ b/backend/internal/service/plugin_compatibility_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "testing" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvaluatePluginCompatibility(t *testing.T) { + manifest := testPluginManifest(nil) + host := PluginHostInfo{Version: "0.1.179", BuildType: "release"} + + result := EvaluatePluginCompatibility(manifest, host) + require.True(t, result.Compatible) + assert.True(t, result.Tested) + assert.Equal(t, "compatible", result.Status) + + manifest.Requires.TestedSub2APIVersions = []string{"0.1.178"} + result = EvaluatePluginCompatibility(manifest, host) + require.True(t, result.Compatible) + assert.False(t, result.Tested) + assert.Equal(t, "untested", result.Status) + + manifest.Requires.Sub2API = ">=0.2.0 <0.3.0" + result = EvaluatePluginCompatibility(manifest, host) + assert.False(t, result.Compatible) + assert.Equal(t, "incompatible", result.Status) +} + +func TestEvaluatePluginCompatibilityRejectsProtocolMismatch(t *testing.T) { + manifest := testPluginManifest(nil) + manifest.Requires.PluginProtocol = pluginv1.ProtocolVersion + 1 + + result := EvaluatePluginCompatibility(manifest, PluginHostInfo{Version: "0.1.179"}) + + assert.False(t, result.Compatible) + assert.Equal(t, "incompatible", result.Status) +} + +func TestMatchesSemverRange(t *testing.T) { + assert.True(t, matchesSemverRange("0.1.179", ">=0.1.170, <0.2.0")) + assert.True(t, matchesSemverRange("v1.2.3", "=1.2.3")) + assert.False(t, matchesSemverRange("0.1.169", ">=0.1.170 <0.2.0")) + assert.False(t, matchesSemverRange("dev", ">=0.1.0")) + assert.False(t, matchesSemverRange("0.1.179", "^0.1.0")) +} diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go new file mode 100644 index 0000000000..f9f06e3bed --- /dev/null +++ b/backend/internal/service/plugin_manager.go @@ -0,0 +1,1091 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" +) + +const ( + pluginConfigMaxBytes = 4 * 1024 * 1024 + pluginUIAssetMaxBytes = 32 * 1024 * 1024 + pluginReconcilePeriod = time.Second + pluginHealthTimeout = 5 * time.Second + pluginUITokenPrefix = "sub2api:plugin-ui:v1:" +) + +type pluginRoute struct { + pluginID int64 + runtime *pluginRuntime + rolloutPercent int + unavailable string +} + +// PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。 +type PluginManager struct { + repo PluginRepository + encryptor SecretEncryptor + cfg *config.Config + hostInfo PluginHostInfo + installer *PluginPackageInstaller + + operationMu sync.Mutex + mu sync.Mutex + runtimes map[int64]*pluginRuntime + localInstallations map[int64]*PluginInstallation + started bool + reconcileCancel context.CancelFunc + reconcileDone chan struct{} + route atomic.Pointer[pluginRoute] +} + +func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager { + return &PluginManager{ + repo: repo, + encryptor: encryptor, + cfg: cfg, + hostInfo: hostInfo, + installer: NewPluginPackageInstaller(cfg, hostInfo), + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } +} + +func (m *PluginManager) MaxUploadBytes() int64 { + if m == nil || m.cfg == nil { + return 0 + } + return m.cfg.Plugins.MaxUploadBytes +} + +func (m *PluginManager) Start(ctx context.Context) error { + m.operationMu.Lock() + m.mu.Lock() + if m.started { + m.mu.Unlock() + m.operationMu.Unlock() + return nil + } + if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil { + m.mu.Unlock() + m.operationMu.Unlock() + return fmt.Errorf("创建插件运行目录: %w", err) + } + reconcileCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + m.started = true + m.reconcileCancel = cancel + m.reconcileDone = make(chan struct{}) + done := m.reconcileDone + m.mu.Unlock() + m.operationMu.Unlock() + + go m.reconcileLoop(reconcileCtx, done) + if err := m.reconcileOnce(reconcileCtx); err != nil { + slog.Warn("plugin_initial_reconcile_failed", "error", err) + } + return nil +} + +func (m *PluginManager) Stop() { + m.mu.Lock() + cancel := m.reconcileCancel + done := m.reconcileDone + m.reconcileCancel = nil + m.reconcileDone = nil + m.mu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } + m.operationMu.Lock() + m.mu.Lock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for _, runtime := range m.runtimes { + runtimes = append(runtimes, runtime) + } + m.runtimes = make(map[int64]*pluginRuntime) + m.route.Store(nil) + m.started = false + m.mu.Unlock() + m.operationMu.Unlock() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) List(ctx context.Context) ([]*PluginInstallation, error) { + plugins, err := m.repo.List(ctx) + if err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + route := m.route.Load() + for _, installation := range plugins { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if runtime := m.runtimes[installation.ID]; runtime != nil && !runtime.client.Exited() { + installation.RuntimeHealthy = true + installation.RuntimeMessage = "插件进程运行中" + } else if installation.State == PluginStateEnabled { + installation.RuntimeMessage = installation.LastError + } + if route != nil && route.pluginID == installation.ID && route.runtime == nil { + installation.RuntimeMessage = route.unavailable + } + } + return plugins, nil +} + +func (m *PluginManager) Get(ctx context.Context, id int64) (*PluginInstallation, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + installation.RuntimeMessage = "插件进程运行中" + } else if route := m.route.Load(); route != nil && route.pluginID == id { + installation.RuntimeMessage = route.unavailable + } + return installation, nil +} + +func (m *PluginManager) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + packageInfo, err := m.installer.Install(ctx, reader, installedBy) + if err != nil { + return nil, err + } + var previous *PluginInstallation + if existing, getErr := m.repo.GetByKey(ctx, packageInfo.PluginKey); getErr == nil { + if existing.State == PluginStateEnabled || hasEnabledOpenAIBinding(existing.Bindings) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(errors.New("请先停用当前插件,再上传同 ID 的新版本"), cleanupErr) + } + previous = existing + } else if !errors.Is(getErr, sql.ErrNoRows) { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(getErr, cleanupErr) + } + bindings := make([]PluginBinding, 0, len(packageInfo.Manifest.Capabilities)) + for _, capability := range packageInfo.Manifest.SortedCapabilities() { + bindings = append(bindings, PluginBinding{ + Capability: capability.ID, + Platform: capability.Platform, + AccountType: capability.AccountType, + Enabled: false, + RolloutPercent: 100, + }) + } + installed, err := m.repo.Install(ctx, packageInfo, bindings) + if err != nil { + cleanupErr := m.cleanupInstallationFiles(packageInfo) + return nil, errors.Join(err, cleanupErr) + } + local := *packageInfo + local.ID = installed.ID + local.ConfigEncrypted = installed.ConfigEncrypted + local.Bindings = append([]PluginBinding(nil), installed.Bindings...) + m.mu.Lock() + localPrevious := m.localInstallations[installed.ID] + m.localInstallations[installed.ID] = &local + m.mu.Unlock() + if previous != nil { + if cleanupErr := m.cleanupInstallationFiles(previous); cleanupErr != nil { + slog.Warn("plugin_previous_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + if localPrevious != nil && (localPrevious.InstallPath != previous.InstallPath || localPrevious.ArtifactPath != previous.ArtifactPath) { + if cleanupErr := m.cleanupInstallationFiles(localPrevious); cleanupErr != nil { + slog.Warn("plugin_previous_local_install_cleanup_failed", "plugin_id", previous.ID, "error", cleanupErr) + } + } + } + return m.Get(ctx, installed.ID) +} + +func (m *PluginManager) cleanupInstallationFiles(installation *PluginInstallation) error { + if installation == nil { + return nil + } + var cleanupErr error + for _, path := range []string{installation.ArtifactPath, installation.InstallPath} { + if err := m.removeManagedPath(path); err != nil { + cleanupErr = errors.Join(cleanupErr, err) + } + } + return cleanupErr +} + +func (m *PluginManager) reconcileLoop(ctx context.Context, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(pluginReconcilePeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.reconcileOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("plugin_reconcile_failed", "error", err) + } + } + } +} + +// reconcileOnce 以数据库中的绑定为权威状态,让每个实例独立恢复并启动同一插件。 +func (m *PluginManager) reconcileOnce(ctx context.Context) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + + installations, err := m.repo.List(ctx) + if err != nil { + // 无法读取权威绑定状态时不能假设插件未启用,否则会把 OAuth 请求静默回落到旧直连路径。 + m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取") + return fmt.Errorf("读取插件启用状态: %w", err) + } + m.cleanupStaleLocalInstallations(installations) + var enabled *PluginInstallation + for _, installation := range installations { + if !hasEnabledOpenAIBinding(installation.Bindings) { + continue + } + if enabled != nil { + err := errors.New("检测到多个 OpenAI OAuth 出站插件同时启用") + m.publishUnavailableRoute(enabled.ID, 100, err.Error()) + return err + } + enabled = installation + } + if enabled == nil { + for _, installation := range installations { + if installation.State != PluginStateStarting || !m.startingStateExpired(installation) { + continue + } + if err := m.repo.UpdateState( + ctx, installation.ID, PluginStateDisabled, "插件启动超时,已自动恢复为停用状态", nil, + installation.BinarySHA256, PluginStateStarting, + ); err != nil && !errors.Is(err, ErrPluginStateChanged) { + return fmt.Errorf("恢复超时插件状态: %w", err) + } + } + runtimes := m.detachAllRuntimes() + for _, runtime := range runtimes { + runtime.drain(10 * time.Second) + } + return nil + } + + rollout := bindingRollout(enabled.Bindings) + current := m.route.Load() + if current != nil && current.pluginID == enabled.ID && current.runtime != nil && + !current.runtime.client.Exited() && current.rolloutPercent == rollout && + current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 && + current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted { + healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout) + healthErr := current.runtime.checkHealth(healthCtx) + cancel() + if healthErr != nil { + if stateErr := m.markRuntimeUnavailable(current, healthErr.Error()); stateErr != nil { + return errors.Join(healthErr, stateErr) + } + return healthErr + } + if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) { + return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted) + } + return nil + } + if enabled.State == PluginStateStarting && !m.startingStateExpired(enabled) { + if current == nil { + m.route.Store(&pluginRoute{pluginID: enabled.ID, rolloutPercent: rollout, unavailable: "插件正在其他实例中启动"}) + } + return nil + } + + local, err := m.ensureLocalInstallation(ctx, enabled) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + runtime, err := m.prepareRuntime(ctx, local, true) + if err != nil { + m.publishUnavailableRoute(enabled.ID, rollout, err.Error()) + return err + } + + // 启动进程期间绑定可能已在其他实例上变化,发布前必须重新确认。 + latest, err := m.repo.GetByID(ctx, enabled.ID) + if err != nil { + runtime.kill() + return err + } + if !hasEnabledOpenAIBinding(latest.Bindings) || latest.BinarySHA256 != enabled.BinarySHA256 || + latest.ConfigEncrypted != enabled.ConfigEncrypted || bindingRollout(latest.Bindings) != rollout { + runtime.kill() + return nil + } + if latest.State == PluginStateStarting && !m.startingStateExpired(latest) { + runtime.kill() + return nil + } + if err := m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil + } + return err + } + + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, candidate := range m.runtimes { + if candidate != runtime { + candidate.draining.Store(true) + stale = append(stale, candidate) + } + if id != enabled.ID { + delete(m.runtimes, id) + } + } + m.runtimes[enabled.ID] = runtime + m.route.Store(&pluginRoute{pluginID: enabled.ID, runtime: runtime, rolloutPercent: rollout}) + m.mu.Unlock() + for _, candidate := range stale { + candidate.drain(10 * time.Second) + } + return nil +} + +func (m *PluginManager) startingStateExpired(installation *PluginInstallation) bool { + if installation == nil || installation.UpdatedAt.IsZero() { + return false + } + startTimeout := 15 * time.Second + if m.cfg != nil && m.cfg.Plugins.StartTimeoutSeconds > 0 { + startTimeout = time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + } + recoveryDelay := startTimeout + 45*time.Second + if recoveryDelay < time.Minute { + recoveryDelay = time.Minute + } + return time.Since(installation.UpdatedAt) > recoveryDelay +} + +func (m *PluginManager) detachAllRuntimes() []*pluginRuntime { + m.mu.Lock() + defer m.mu.Unlock() + runtimes := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + runtimes = append(runtimes, runtime) + delete(m.runtimes, id) + } + m.route.Store(nil) + return runtimes +} + +func (m *PluginManager) publishUnavailableRoute(pluginID int64, rollout int, message string) { + m.mu.Lock() + stale := make([]*pluginRuntime, 0, len(m.runtimes)) + for id, runtime := range m.runtimes { + runtime.draining.Store(true) + stale = append(stale, runtime) + delete(m.runtimes, id) + } + m.route.Store(&pluginRoute{pluginID: pluginID, rolloutPercent: rollout, unavailable: message}) + m.mu.Unlock() + for _, runtime := range stale { + runtime.drain(10 * time.Second) + } +} + +func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installation *PluginInstallation) (*PluginInstallation, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + m.mu.Lock() + local := m.localInstallations[installation.ID] + m.mu.Unlock() + if local != nil && local.BinarySHA256 == installation.BinarySHA256 && local.Version == installation.Version { + if err := verifyLocalPluginBinary(local, m.installer.RootDir()); err == nil { + return mergeLocalInstallation(local, installation), nil + } + } + if err := verifyLocalPluginBinary(installation, m.installer.RootDir()); err == nil { + local = mergeLocalInstallation(installation, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil + } + + artifact, err := m.repo.GetArtifact(ctx, installation.ID) + if err != nil { + return nil, fmt.Errorf("读取插件包原件: %w", err) + } + if len(artifact) == 0 { + return nil, errors.New("插件包原件缺失,请重新上传插件") + } + restored, err := m.installer.Install(ctx, bytes.NewReader(artifact), installation.InstalledBy) + if err != nil { + return nil, fmt.Errorf("恢复并复验插件包: %w", err) + } + if !samePluginPackage(restored, installation) { + cleanupErr := m.cleanupInstallationFiles(restored) + return nil, errors.Join(errors.New("数据库插件包与安装记录不一致"), cleanupErr) + } + local = mergeLocalInstallation(restored, installation) + m.mu.Lock() + m.localInstallations[installation.ID] = local + m.mu.Unlock() + return local, nil +} + +// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。 +// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。 +func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) { + persisted := make(map[int64]*PluginInstallation, len(installations)) + for _, installation := range installations { + if installation != nil { + persisted[installation.ID] = installation + } + } + m.mu.Lock() + stale := make([]*PluginInstallation, 0) + for id, local := range m.localInstallations { + current := persisted[id] + if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version { + stale = append(stale, local) + delete(m.localInstallations, id) + } + } + m.mu.Unlock() + for _, local := range stale { + if err := m.cleanupInstallationFiles(local); err != nil { + slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err) + } + } +} + +func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation { + merged := *persisted + merged.ArtifactData = nil + merged.ArtifactPath = local.ArtifactPath + merged.InstallPath = local.InstallPath + merged.BinaryPath = local.BinaryPath + merged.Bindings = append([]PluginBinding(nil), persisted.Bindings...) + return &merged +} + +func samePluginPackage(local, persisted *PluginInstallation) bool { + if local == nil || persisted == nil || local.PluginKey != persisted.PluginKey || + local.Version != persisted.Version || local.BinarySHA256 != persisted.BinarySHA256 { + return false + } + localManifest, localErr := json.Marshal(local.Manifest) + persistedManifest, persistedErr := json.Marshal(persisted.Manifest) + return localErr == nil && persistedErr == nil && bytes.Equal(localManifest, persistedManifest) +} + +func verifyLocalPluginBinary(installation *PluginInstallation, root string) error { + if installation == nil { + return errors.New("插件安装记录为空") + } + rootPath, err := filepath.Abs(root) + if err != nil { + return err + } + installPath, err := filepath.Abs(installation.InstallPath) + if err != nil { + return err + } + relative, err := filepath.Rel(rootPath, installPath) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("插件安装目录不在受管目录内") + } + runtimeEntry, ok := installation.Manifest.Runtimes[installation.Manifest.RuntimeKey()] + if !ok { + return errors.New("插件未声明当前平台运行时") + } + binaryPath, err := safePluginJoin(installPath, runtimeEntry.Path) + if err != nil { + return err + } + data, err := os.ReadFile(binaryPath) + if err != nil { + return err + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.BinarySHA256 { + return errors.New("本地插件二进制哈希不匹配") + } + installation.BinaryPath = binaryPath + return nil +} + +func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested bool, rolloutPercent int) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if rolloutPercent < 1 || rolloutPercent > 100 { + return nil, errors.New("灰度比例必须在 1 到 100 之间") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if active := m.route.Load(); active != nil && active.pluginID != id { + return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件") + } + if installation.State == PluginStateEnabled && hasEnabledOpenAIBinding(installation.Bindings) { + installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + installation.RuntimeHealthy = runtime != nil && !runtime.client.Exited() + if installation.RuntimeHealthy { + return installation, nil + } + } + compatibility := EvaluatePluginCompatibility(installation.Manifest, m.hostInfo) + if !compatibility.Compatible { + stateErr := m.repo.UpdateState(ctx, id, PluginStateIncompatible, compatibility.Message, nil, installation.BinarySHA256, installation.State) + return nil, errors.Join(errors.New(compatibility.Message), stateErr) + } + if !compatibility.Tested && !acceptUntested { + return nil, errors.New("插件未声明已测试当前 Sub2API 版本,需要管理员确认后启用") + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + originalBindings := append([]PluginBinding(nil), installation.Bindings...) + for index := range installation.Bindings { + installation.Bindings[index].Enabled = true + installation.Bindings[index].RolloutPercent = rolloutPercent + } + if err := m.repo.BeginEnable(ctx, id, installation.BinarySHA256, installation.State); err != nil { + return nil, err + } + runtime, err := m.prepareRuntime(ctx, installation, true) + if err != nil { + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + if hasEnabledOpenAIBinding(originalBindings) { + m.route.Store(&pluginRoute{pluginID: id, rolloutPercent: bindingRollout(originalBindings), unavailable: err.Error()}) + } + return nil, errors.Join(err, stateErr) + } + now := time.Now() + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateEnabled, "", &now, PluginStateStarting, installation.BinarySHA256); err != nil { + runtime.kill() + if errors.Is(err, ErrPluginStateChanged) { + return nil, err + } + stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + stateErr := m.repo.UpdateState(stateCtx, id, PluginStateError, err.Error(), nil, installation.BinarySHA256, PluginStateStarting) + cancel() + return nil, errors.Join(err, stateErr) + } + m.mu.Lock() + m.publishRuntimeLocked(installation, runtime) + m.mu.Unlock() + result, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + result.Compatibility = compatibility + result.RuntimeHealthy = true + result.RuntimeMessage = "插件进程运行中" + return result, nil +} + +func (m *PluginManager) Disable(ctx context.Context, id int64) (*PluginInstallation, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return nil, err + } + for index := range installation.Bindings { + installation.Bindings[index].Enabled = false + } + if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateDisabled, "", nil, "", installation.BinarySHA256); err != nil { + m.mu.Unlock() + return nil, err + } + runtime := m.removeRuntimeLocked(id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + return m.Get(ctx, id) +} + +func (m *PluginManager) Delete(ctx context.Context, id int64) error { + m.operationMu.Lock() + defer m.operationMu.Unlock() + m.mu.Lock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + m.mu.Unlock() + return err + } + if installation.State == PluginStateEnabled || hasEnabledOpenAIBinding(installation.Bindings) { + m.mu.Unlock() + return errors.New("请先停用插件,再执行卸载") + } + if err := m.repo.Delete(ctx, id, installation.BinarySHA256); err != nil { + m.mu.Unlock() + return err + } + runtime := m.removeRuntimeLocked(id) + local := m.localInstallations[id] + delete(m.localInstallations, id) + m.mu.Unlock() + if runtime != nil { + runtime.drain(10 * time.Second) + } + cleanupErr := m.cleanupInstallationFiles(installation) + if local != nil && (local.InstallPath != installation.InstallPath || local.ArtifactPath != installation.ArtifactPath) { + cleanupErr = errors.Join(cleanupErr, m.cleanupInstallationFiles(local)) + } + return cleanupErr +} + +func (m *PluginManager) GetConfig(ctx context.Context, id int64) (json.RawMessage, error) { + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return m.decryptConfig(installation) +} + +func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMessage) (json.RawMessage, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + if len(raw) == 0 || len(raw) > pluginConfigMaxBytes || !json.Valid(raw) { + return nil, errors.New("插件配置必须是有效且大小受限的 JSON") + } + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + previousConfig, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + var normalized any + if err := json.Unmarshal(raw, &normalized); err != nil { + return nil, err + } + if normalized == nil { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件配置 JSON 根节点必须是对象") + } + canonical, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + defer runtime.kill() + } + if runtime != nil { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + canonical, err = runtime.validateAndApplyNormalizedConfig(applyCtx, canonical) + cancel() + if err != nil { + return nil, err + } + } + encrypted, err := m.encryptor.Encrypt(string(canonical)) + if err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, fmt.Errorf("加密插件配置: %w", err) + } + if err := m.repo.UpdateConfig(ctx, id, encrypted, installation.BinarySHA256); err != nil { + if !temporary { + err = errors.Join(err, m.restoreRuntimeConfig(id, runtime, previousConfig)) + } + return nil, err + } + if !temporary { + runtime.installation.ConfigEncrypted = encrypted + } + return canonical, nil +} + +func (m *PluginManager) restoreRuntimeConfig(id int64, runtime *pluginRuntime, previous json.RawMessage) error { + if runtime == nil { + return nil + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(rollbackCtx, previous); err != nil { + route := m.route.Load() + if route != nil && route.pluginID == id && route.runtime == runtime { + stateErr := m.markRuntimeUnavailable(route, "插件配置回滚失败: "+err.Error()) + return errors.Join(err, stateErr) + } + return err + } + return nil +} + +func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfigResponse, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err != nil { + return nil, err + } + m.mu.Lock() + runtime := m.runtimes[id] + m.mu.Unlock() + temporary := false + if runtime == nil { + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, err + } + runtime, err = m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + temporary = true + } + if temporary { + defer runtime.kill() + } + testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := runtime.validateAndApplyConfig(testCtx, configJSON); err != nil { + return nil, err + } + return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON}) +} + +type pluginUIAssetClaims struct { + Version int `json:"version"` + PluginID int64 `json:"plugin_id"` + Expires int64 `json:"expires"` +} + +// CreateUIAssetToken 创建可跨实例校验的短时能力令牌,令牌不包含管理员凭据。 +func (m *PluginManager) CreateUIAssetToken(ctx context.Context, id int64, ttl time.Duration) (string, time.Time, error) { + if ttl <= 0 || ttl > time.Hour { + return "", time.Time{}, errors.New("插件 UI 会话有效期无效") + } + if _, err := m.repo.GetByID(ctx, id); err != nil { + return "", time.Time{}, err + } + expires := time.Now().Add(ttl) + raw, err := json.Marshal(pluginUIAssetClaims{Version: 1, PluginID: id, Expires: expires.Unix()}) + if err != nil { + return "", time.Time{}, err + } + // 加用途前缀,避免复用同一 AES-GCM 密钥的其他密文被当作 UI 能力令牌。 + encrypted, err := m.encryptor.Encrypt(pluginUITokenPrefix + string(raw)) + if err != nil { + return "", time.Time{}, fmt.Errorf("加密插件 UI 会话: %w", err) + } + return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), expires, nil +} + +func (m *PluginManager) ResolveUIAssetToken(token string) (int64, error) { + if len(token) == 0 || len(token) > 4096 { + return 0, errors.New("插件 UI 会话无效") + } + encrypted, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, err := m.encryptor.Decrypt(string(encrypted)) + if err != nil { + return 0, errors.New("插件 UI 会话无效") + } + plaintext, ok := strings.CutPrefix(plaintext, pluginUITokenPrefix) + if !ok { + return 0, errors.New("插件 UI 会话无效") + } + var claims pluginUIAssetClaims + decoder := json.NewDecoder(strings.NewReader(plaintext)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&claims); err != nil || claims.Version != 1 || claims.PluginID <= 0 { + return 0, errors.New("插件 UI 会话无效") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return 0, errors.New("插件 UI 会话无效") + } + now := time.Now().Unix() + if now >= claims.Expires { + return 0, errors.New("插件 UI 会话已过期") + } + if claims.Expires > now+int64(time.Hour/time.Second) { + return 0, errors.New("插件 UI 会话无效") + } + return claims.PluginID, nil +} + +func (m *PluginManager) ReadUIAsset(ctx context.Context, id int64, relative string) ([]byte, string, error) { + m.operationMu.Lock() + defer m.operationMu.Unlock() + installation, err := m.repo.GetByID(ctx, id) + if err != nil { + return nil, "", err + } + installation, err = m.ensureLocalInstallation(ctx, installation) + if err != nil { + return nil, "", err + } + path := strings.TrimPrefix(strings.ReplaceAll(relative, "\\", "/"), "/") + if path == "" || path == "index.html" { + path = installation.Manifest.UI.Entrypoint + } else { + path = "ui/" + path + } + if _, declared := installation.Manifest.Files[path]; !declared || !strings.HasPrefix(path, "ui/") { + return nil, "", os.ErrNotExist + } + fullPath, err := safePluginJoin(installation.InstallPath, path) + if err != nil { + return nil, "", err + } + file, err := os.Open(fullPath) + if err != nil { + return nil, "", err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, pluginUIAssetMaxBytes+1)) + if err != nil { + return nil, "", err + } + if len(data) > pluginUIAssetMaxBytes { + return nil, "", errors.New("插件 UI 资源超过大小限制") + } + digest := sha256.Sum256(data) + if hex.EncodeToString(digest[:]) != installation.Manifest.Files[path] { + return nil, "", errors.New("插件 UI 资源哈希不匹配") + } + return data, path, nil +} + +func (m *PluginManager) RoundTripOpenAIOAuth(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, bool, error) { + if !m.ShouldRouteOpenAIOAuth(account) { + return nil, false, nil + } + route := m.route.Load() + if route == nil { + return nil, false, nil + } + if route.runtime == nil { + return nil, true, fmt.Errorf("OpenAI OAuth 插件不可用: %s", route.unavailable) + } + if route.runtime.client.Exited() { + runtimeErr := errors.New("OpenAI OAuth 插件进程已退出") + if stateErr := m.markRuntimeUnavailable(route, runtimeErr.Error()); stateErr != nil { + return nil, true, errors.Join(runtimeErr, stateErr) + } + return nil, true, runtimeErr + } + if !route.runtime.beginRequest() { + return nil, true, errors.New("OpenAI OAuth 插件正在停止") + } + response, err := route.runtime.roundTrip(ctx, request, proxyURL, account) + if err != nil { + route.runtime.finishRequest() + if route.runtime.client.Exited() { + if stateErr := m.markRuntimeUnavailable(route, err.Error()); stateErr != nil { + err = errors.Join(err, stateErr) + } + } + return nil, true, err + } + return response, true, nil +} + +// ShouldRouteOpenAIOAuth 判断该账号是否命中当前 OpenAI OAuth 插件绑定。 +// WebSocket 入口用它把命中的账号切换到 HTTP Bridge,避免绕过 v1 HTTP 插件协议。 +func (m *PluginManager) ShouldRouteOpenAIOAuth(account *Account) bool { + if m == nil || account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return false + } + route := m.route.Load() + return route != nil && route.rolloutPercent > 0 && int(stablePluginBucket(account.ID)) < route.rolloutPercent +} + +func (m *PluginManager) markRuntimeUnavailable(failedRoute *pluginRoute, message string) error { + m.mu.Lock() + current := m.route.Load() + if current != failedRoute { + m.mu.Unlock() + return nil + } + if failedRoute.runtime != nil { + failedRoute.runtime.kill() + } + delete(m.runtimes, failedRoute.pluginID) + m.route.Store(&pluginRoute{ + pluginID: failedRoute.pluginID, + rolloutPercent: failedRoute.rolloutPercent, + unavailable: message, + }) + m.mu.Unlock() + return nil +} + +func (m *PluginManager) prepareRuntime(ctx context.Context, installation *PluginInstallation, validateConfig bool) (*pluginRuntime, error) { + runtime, err := m.newRuntime(ctx, installation) + if err != nil { + return nil, err + } + configJSON, err := m.decryptConfig(installation) + if err == nil && validateConfig { + applyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + err = runtime.validateAndApplyConfig(applyCtx, configJSON) + cancel() + } + if err != nil { + runtime.kill() + return nil, err + } + return runtime, nil +} + +func (m *PluginManager) publishRuntimeLocked(installation *PluginInstallation, runtime *pluginRuntime) { + if old := m.runtimes[installation.ID]; old != nil { + old.kill() + } + m.runtimes[installation.ID] = runtime + m.route.Store(&pluginRoute{ + pluginID: installation.ID, + runtime: runtime, + rolloutPercent: bindingRollout(installation.Bindings), + }) +} + +func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInstallation) (*pluginRuntime, error) { + socketDir := filepath.Join(m.installer.RootDir(), "runtime") + if err := os.MkdirAll(socketDir, 0o700); err != nil { + return nil, err + } + timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second + return startPluginRuntime(ctx, installation, timeout, socketDir) +} + +func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime { + runtime := m.runtimes[id] + delete(m.runtimes, id) + if route := m.route.Load(); route != nil && route.pluginID == id { + m.route.Store(nil) + } + return runtime +} + +func (m *PluginManager) decryptConfig(installation *PluginInstallation) (json.RawMessage, error) { + if installation == nil || strings.TrimSpace(installation.ConfigEncrypted) == "" { + return json.RawMessage(`{}`), nil + } + plaintext, err := m.encryptor.Decrypt(installation.ConfigEncrypted) + if err != nil { + return nil, fmt.Errorf("解密插件配置: %w", err) + } + if !json.Valid([]byte(plaintext)) { + return nil, errors.New("已保存的插件配置不是有效 JSON") + } + return json.RawMessage(plaintext), nil +} + +func (m *PluginManager) removeManagedPath(target string) error { + if strings.TrimSpace(target) == "" { + return nil + } + root, err := filepath.Abs(m.installer.RootDir()) + if err != nil { + return err + } + absTarget, err := filepath.Abs(target) + if err != nil { + return err + } + relative, err := filepath.Rel(root, absTarget) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("拒绝删除插件根目录之外的路径") + } + return os.RemoveAll(absTarget) +} + +func hasEnabledOpenAIBinding(bindings []PluginBinding) bool { + for _, binding := range bindings { + if binding.Enabled && binding.Capability == PluginCapabilityOpenAIOAuthOutbound && + binding.Platform == PlatformOpenAI && binding.AccountType == AccountTypeOAuth { + return true + } + } + return false +} + +func bindingRollout(bindings []PluginBinding) int { + for _, binding := range bindings { + if binding.Capability == PluginCapabilityOpenAIOAuthOutbound { + return binding.RolloutPercent + } + } + return 100 +} + +func stablePluginBucket(accountID int64) uint64 { + value := uint64(accountID) + value ^= value >> 33 + value *= 0xff51afd7ed558ccd + value ^= value >> 33 + return value % 100 +} diff --git a/backend/internal/service/plugin_manager_routing_test.go b/backend/internal/service/plugin_manager_routing_test.go new file mode 100644 index 0000000000..b2f4654cfe --- /dev/null +++ b/backend/internal/service/plugin_manager_routing_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pluginRoutingHTTPUpstream struct { + doCalls int + doWithTLSCalls int +} + +func (u *pluginRoutingHTTPUpstream) Do(*http.Request, string, int64, int) (*http.Response, error) { + u.doCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("legacy")), + }, nil +} + +func (u *pluginRoutingHTTPUpstream) DoWithTLS( + request *http.Request, + proxyURL string, + accountID int64, + accountConcurrency int, + _ *tlsfingerprint.Profile, +) (*http.Response, error) { + u.doWithTLSCalls++ + return u.Do(request, proxyURL, accountID, accountConcurrency) +} + +func TestPluginManagerRoutingDoesNotTouchAPIKeyOrOtherProviders(t *testing.T) { + manager := &PluginManager{} + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + + accounts := []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + {ID: 2, Platform: PlatformAnthropic, Type: AccountTypeOAuth}, + {ID: 3, Platform: PlatformGemini, Type: AccountTypeOAuth}, + } + for _, account := range accounts { + response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account) + assert.Nil(t, response) + assert.False(t, handled) + assert.NoError(t, routeErr) + } +} + +func TestPluginManagerRoutingKeepsOAuthOnLegacyPathWithoutEnabledBinding(t *testing.T) { + manager := &PluginManager{} + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + response, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", account) + + assert.Nil(t, response) + assert.False(t, handled) + assert.NoError(t, routeErr) +} + +func TestPluginManagerRoutingSelectsOnlyEligibleOpenAIOAuthAccounts(t *testing.T) { + manager := &PluginManager{} + manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"}) + + assert.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeOAuth})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(&Account{ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth})) + assert.False(t, manager.ShouldRouteOpenAIOAuth(nil)) +} + +func TestOpenAIGatewayPluginRoutingPreservesAPIKeyAndFailsClosedForOAuth(t *testing.T) { + manager := &PluginManager{} + manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "测试不可用"}) + upstream := &pluginRoutingHTTPUpstream{} + service := &OpenAIGatewayService{pluginManager: manager, httpUpstream: upstream} + + apiKeyRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + apiKeyResponse, err := service.doOpenAIUpstream(apiKeyRequest, "", &Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, + }) + require.NoError(t, err) + require.NotNil(t, apiKeyResponse) + _ = apiKeyResponse.Body.Close() + assert.Equal(t, 1, upstream.doCalls) + + oauthRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, err) + oauthResponse, err := service.doOpenAIUpstream(oauthRequest, "", &Account{ + ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + }) + require.Error(t, err) + assert.Nil(t, oauthResponse) + assert.Contains(t, err.Error(), "插件不可用") + assert.Equal(t, 1, upstream.doCalls) +} + +func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) { + for id := int64(1); id <= 1000; id++ { + first := stablePluginBucket(id) + assert.Equal(t, first, stablePluginBucket(id)) + assert.Less(t, first, uint64(100)) + } +} diff --git a/backend/internal/service/plugin_manifest.go b/backend/internal/service/plugin_manifest.go new file mode 100644 index 0000000000..b06e3d352d --- /dev/null +++ b/backend/internal/service/plugin_manifest.go @@ -0,0 +1,214 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "runtime" + "sort" + "strings" + "time" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" +) + +const ( + PluginCapabilityOpenAIOAuthOutbound = "openai.oauth.outbound_transport.v1" + PluginStateDisabled = "disabled" + PluginStateStarting = "starting" + PluginStateEnabled = "enabled" + PluginStateError = "error" + PluginStateIncompatible = "incompatible" + PluginSignatureTrusted = "trusted" + PluginSignatureUnsigned = "unsigned" +) + +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`) + +var ErrPluginStateChanged = errors.New("插件状态已在其他实例中变化,请刷新后重试") + +// PluginManifest 是 .s2plugin 包中可在执行二进制前检查的声明。 +type PluginManifest struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Requires PluginRequirements `json:"requires"` + Capabilities []PluginCapability `json:"capabilities"` + Runtimes map[string]PluginRuntime `json:"runtimes"` + UI PluginUIManifest `json:"ui"` + Files map[string]string `json:"files"` +} + +type PluginRequirements struct { + Sub2API string `json:"sub2api"` + RecommendedSub2APIVersion string `json:"recommended_sub2api_version,omitempty"` + TestedSub2APIVersions []string `json:"tested_sub2api_versions,omitempty"` + PluginProtocol int `json:"plugin_protocol"` + TransportAPI int `json:"transport_api"` + UIBridge int `json:"ui_bridge"` +} + +type PluginCapability struct { + ID string `json:"id"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` +} + +type PluginRuntime struct { + Path string `json:"path"` +} + +type PluginUIManifest struct { + Entrypoint string `json:"entrypoint"` +} + +type PluginSignature struct { + Algorithm string `json:"algorithm"` + KeyID string `json:"key_id"` + Signature string `json:"signature"` +} + +// PluginCompatibility 是管理页面展示和启用门禁共同使用的兼容性结论。 +type PluginCompatibility struct { + Compatible bool `json:"compatible"` + Tested bool `json:"tested"` + Status string `json:"status"` + Message string `json:"message"` + CurrentSub2API string `json:"current_sub2api_version"` + RequiredSub2API string `json:"required_sub2api_version"` + RecommendedSub2API string `json:"recommended_sub2api_version"` + PluginProtocol int `json:"plugin_protocol"` + TransportAPI int `json:"transport_api"` + UIBridge int `json:"ui_bridge"` +} + +type PluginInstallation struct { + ID int64 `json:"id"` + PluginKey string `json:"plugin_key"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Manifest PluginManifest `json:"manifest"` + ArtifactData []byte `json:"-"` + ArtifactPath string `json:"-"` + InstallPath string `json:"-"` + BinaryPath string `json:"-"` + BinarySHA256 string `json:"binary_sha256"` + SignatureStatus string `json:"signature_status"` + State string `json:"state"` + ConfigEncrypted string `json:"-"` + LastError string `json:"last_error"` + InstalledBy *int64 `json:"installed_by"` + InstalledAt time.Time `json:"installed_at"` + EnabledAt *time.Time `json:"enabled_at"` + UpdatedAt time.Time `json:"updated_at"` + Bindings []PluginBinding `json:"bindings"` + Compatibility PluginCompatibility `json:"compatibility"` + RuntimeHealthy bool `json:"runtime_healthy"` + RuntimeMessage string `json:"runtime_message"` +} + +type PluginBinding struct { + ID int64 `json:"id"` + PluginID int64 `json:"plugin_id"` + Capability string `json:"capability"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` + Enabled bool `json:"enabled"` + RolloutPercent int `json:"rollout_percent"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PluginRepository interface { + List(ctx context.Context) ([]*PluginInstallation, error) + GetByID(ctx context.Context, id int64) (*PluginInstallation, error) + GetByKey(ctx context.Context, key string) (*PluginInstallation, error) + Install(ctx context.Context, plugin *PluginInstallation, bindings []PluginBinding) (*PluginInstallation, error) + GetArtifact(ctx context.Context, id int64) ([]byte, error) + Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error + BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error + MarkRuntimeHealthy(ctx context.Context, id int64, binarySHA256, configEncrypted string) error + UpdateState(ctx context.Context, id int64, state, lastError string, enabledAt *time.Time, expectedBinarySHA256, expectedState string) error + UpdateConfig(ctx context.Context, id int64, encrypted, expectedBinarySHA256 string) error + UpdateBindingsAndState(ctx context.Context, pluginID int64, bindings []PluginBinding, state, lastError string, enabledAt *time.Time, expectedState, expectedBinarySHA256 string) error +} + +func (m PluginManifest) RuntimeKey() string { + return runtime.GOOS + "-" + runtime.GOARCH +} + +func (m PluginManifest) Validate() error { + if m.SchemaVersion != 1 { + return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion) + } + if !pluginIDPattern.MatchString(m.ID) || len(m.ID) > 160 { + return errors.New("插件 ID 必须是长度不超过 160 的小写命名空间标识") + } + if strings.TrimSpace(m.Name) == "" || len(m.Name) > 160 { + return errors.New("插件名称不能为空且不能超过 160 个字符") + } + if normalizeSemver(m.Version) == "" { + return errors.New("插件版本必须是有效的语义化版本") + } + if strings.TrimSpace(m.Requires.Sub2API) == "" { + return errors.New("插件必须声明 requires.sub2api") + } + if m.Requires.PluginProtocol != pluginv1.ProtocolVersion || + m.Requires.TransportAPI != pluginv1.TransportAPIVersion || + m.Requires.UIBridge != pluginv1.UIBridgeVersion { + return errors.New("插件协议、传输 API 或 UI Bridge 版本与当前宿主不兼容") + } + if len(m.Capabilities) == 0 { + return errors.New("插件必须声明至少一个能力") + } + for _, capability := range m.Capabilities { + if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth { + return fmt.Errorf("初期仅支持能力 %s", PluginCapabilityOpenAIOAuthOutbound) + } + } + runtimeEntry, ok := m.Runtimes[m.RuntimeKey()] + if !ok || !safePluginRelativePath(runtimeEntry.Path) { + return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey()) + } + if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") { + return errors.New("插件 UI 入口必须位于 ui/ 目录") + } + if len(m.Files) == 0 { + return errors.New("插件清单必须声明文件哈希") + } + for path, hash := range m.Files { + if !safePluginRelativePath(path) || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(hash) { + return fmt.Errorf("插件文件声明无效: %s", path) + } + } + if _, ok := m.Files[runtimeEntry.Path]; !ok { + return errors.New("运行时二进制未包含在文件哈希声明中") + } + if _, ok := m.Files[m.UI.Entrypoint]; !ok { + return errors.New("UI 入口未包含在文件哈希声明中") + } + return nil +} + +func safePluginRelativePath(path string) bool { + cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") + return cleaned != "" && cleaned != "." && !strings.HasPrefix(cleaned, "/") && + !strings.HasPrefix(cleaned, "../") && !strings.Contains(cleaned, "/../") && cleaned == strings.TrimPrefix(cleaned, "./") +} + +func (m PluginManifest) MarshalJSONBytes() ([]byte, error) { + return json.Marshal(m) +} + +func (m PluginManifest) SortedCapabilities() []PluginCapability { + out := append([]PluginCapability(nil), m.Capabilities...) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go new file mode 100644 index 0000000000..2ef9130b0c --- /dev/null +++ b/backend/internal/service/plugin_package.go @@ -0,0 +1,387 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +const ( + pluginManifestFilename = "manifest.json" + pluginSignatureFilename = "signature.json" + pluginArchiveMaxFiles = 512 + builtInOpenAITransportPluginID = "local.sub2api.openai-transport" + builtInOpenAITransportPublisherKeyID = "sub2api-openai-transport-v1" + builtInOpenAITransportPublisherKeyBase64 = "MqzSXAoG0iVR5kKWrC+mqcCeExkrT6zAr2WpQ4sA+yc=" +) + +type PluginPackageInstaller struct { + cfg *config.Config + hostInfo PluginHostInfo + rootDir string +} + +func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller { + return &PluginPackageInstaller{ + cfg: cfg, + hostInfo: hostInfo, + rootDir: resolvePluginRootDir(cfg), + } +} + +func resolvePluginRootDir(cfg *config.Config) string { + if cfg != nil && strings.TrimSpace(cfg.Plugins.DataDir) != "" { + return filepath.Clean(cfg.Plugins.DataDir) + } + base := strings.TrimSpace(os.Getenv("DATA_DIR")) + if base == "" { + base = "./data" + } + return filepath.Join(base, "plugins") +} + +func (i *PluginPackageInstaller) RootDir() string { + return i.rootDir +} + +func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, installedBy *int64) (*PluginInstallation, error) { + if i == nil || i.cfg == nil { + return nil, errors.New("插件安装器未配置") + } + if err := ctx.Err(); err != nil { + return nil, err + } + stagingDir := filepath.Join(i.rootDir, "staging") + packagesDir := filepath.Join(i.rootDir, "packages") + installedDir := filepath.Join(i.rootDir, "installed") + for _, dir := range []string{stagingDir, packagesDir, installedDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("创建插件目录: %w", err) + } + } + + tempFile, err := os.CreateTemp(stagingDir, "upload-*.s2plugin") + if err != nil { + return nil, fmt.Errorf("创建插件上传临时文件: %w", err) + } + tempPath := tempFile.Name() + committed := false + defer func() { + _ = tempFile.Close() + if !committed { + _ = os.Remove(tempPath) + } + }() + + hasher := sha256.New() + limit := i.cfg.Plugins.MaxUploadBytes + written, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(reader, limit+1)) + if err != nil { + return nil, fmt.Errorf("读取插件包: %w", err) + } + if written > limit { + return nil, fmt.Errorf("插件包超过 %d 字节限制", limit) + } + if err := tempFile.Sync(); err != nil { + return nil, fmt.Errorf("同步插件包: %w", err) + } + if err := tempFile.Close(); err != nil { + return nil, fmt.Errorf("关闭插件包: %w", err) + } + artifactSHA := hex.EncodeToString(hasher.Sum(nil)) + + archive, err := zip.OpenReader(tempPath) + if err != nil { + return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err) + } + defer func() { _ = archive.Close() }() + manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader) + if err != nil { + return nil, err + } + compatibility := EvaluatePluginCompatibility(manifest, i.hostInfo) + initialState := PluginStateDisabled + if !compatibility.Compatible { + initialState = PluginStateIncompatible + } + + installParent := filepath.Join(installedDir, manifest.ID) + if err := os.MkdirAll(installParent, 0o700); err != nil { + return nil, fmt.Errorf("创建插件安装父目录: %w", err) + } + extractPath, err := os.MkdirTemp(installParent, ".install-*") + if err != nil { + return nil, fmt.Errorf("创建插件安装临时目录: %w", err) + } + installNonce := strings.TrimPrefix(filepath.Base(extractPath), ".install-") + installPath := filepath.Join(installParent, manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce) + extracted := false + defer func() { + if !extracted { + _ = os.RemoveAll(extractPath) + } + }() + if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil { + return nil, err + } + if err := os.Rename(extractPath, installPath); err != nil { + return nil, fmt.Errorf("提交插件安装目录: %w", err) + } + extracted = true + + artifactPath := filepath.Join(packagesDir, manifest.ID+"-"+manifest.Version+"-"+artifactSHA[:12]+"-"+installNonce+".s2plugin") + if err := os.Rename(tempPath, artifactPath); err != nil { + _ = os.RemoveAll(installPath) + return nil, fmt.Errorf("保存插件包: %w", err) + } + committed = true + artifactData, err := os.ReadFile(artifactPath) + if err != nil { + _ = os.Remove(artifactPath) + _ = os.RemoveAll(installPath) + return nil, fmt.Errorf("读取已保存插件包: %w", err) + } + runtimeEntry := manifest.Runtimes[manifest.RuntimeKey()] + return &PluginInstallation{ + PluginKey: manifest.ID, + Name: manifest.Name, + Version: manifest.Version, + Description: manifest.Description, + Author: manifest.Author, + Manifest: manifest, + ArtifactData: artifactData, + ArtifactPath: artifactPath, + InstallPath: installPath, + BinaryPath: filepath.Join(installPath, filepath.FromSlash(runtimeEntry.Path)), + BinarySHA256: manifest.Files[runtimeEntry.Path], + SignatureStatus: signatureStatus, + State: initialState, + InstalledBy: installedBy, + Compatibility: compatibility, + }, nil +} + +func (i *PluginPackageInstaller) inspectArchive(archive *zip.Reader) (PluginManifest, []byte, string, error) { + if len(archive.File) == 0 || len(archive.File) > pluginArchiveMaxFiles { + return PluginManifest{}, nil, "", errors.New("插件包文件数量无效") + } + entries := make(map[string]*zip.File, len(archive.File)) + var total uint64 + for _, file := range archive.File { + if file.FileInfo().IsDir() { + if _, err := normalizePluginArchivePath(strings.TrimSuffix(file.Name, "/")); err != nil { + return PluginManifest{}, nil, "", err + } + continue + } + name, err := normalizePluginArchivePath(file.Name) + if err != nil { + return PluginManifest{}, nil, "", err + } + if _, exists := entries[name]; exists { + return PluginManifest{}, nil, "", fmt.Errorf("插件包包含重复路径: %s", name) + } + if file.Mode()&os.ModeSymlink != 0 { + return PluginManifest{}, nil, "", fmt.Errorf("插件包不允许符号链接: %s", name) + } + total += file.UncompressedSize64 + if total > uint64(i.cfg.Plugins.MaxUncompressedBytes) { + return PluginManifest{}, nil, "", errors.New("插件包解压后体积超过限制") + } + entries[name] = file + } + manifestFile := entries[pluginManifestFilename] + if manifestFile == nil { + return PluginManifest{}, nil, "", errors.New("插件包缺少 manifest.json") + } + manifestRaw, err := readPluginZipFile(manifestFile, 2*1024*1024) + if err != nil { + return PluginManifest{}, nil, "", fmt.Errorf("读取插件清单: %w", err) + } + var manifest PluginManifest + decoder := json.NewDecoder(bytes.NewReader(manifestRaw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return PluginManifest{}, nil, "", fmt.Errorf("解析插件清单: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return PluginManifest{}, nil, "", errors.New("插件清单只能包含一个 JSON 对象") + } + if err := manifest.Validate(); err != nil { + return PluginManifest{}, nil, "", err + } + for path := range entries { + if path == pluginManifestFilename || path == pluginSignatureFilename { + continue + } + if _, declared := manifest.Files[path]; !declared { + return PluginManifest{}, nil, "", fmt.Errorf("插件包包含未声明文件: %s", path) + } + } + for path := range manifest.Files { + if entries[path] == nil { + return PluginManifest{}, nil, "", fmt.Errorf("插件包缺少已声明文件: %s", path) + } + } + signatureStatus, err := i.verifySignature(entries[pluginSignatureFilename], manifestRaw, manifest.ID) + if err != nil { + return PluginManifest{}, nil, "", err + } + return manifest, manifestRaw, signatureStatus, nil +} + +func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []byte, pluginID string) (string, error) { + if file == nil { + if i.cfg.Plugins.AllowUnsigned { + return PluginSignatureUnsigned, nil + } + return "", errors.New("生产配置不允许安装未签名插件") + } + raw, err := readPluginZipFile(file, 64*1024) + if err != nil { + return "", fmt.Errorf("读取插件签名: %w", err) + } + var signature PluginSignature + if err := json.Unmarshal(raw, &signature); err != nil { + return "", fmt.Errorf("解析插件签名: %w", err) + } + if signature.Algorithm != "ed25519" || strings.TrimSpace(signature.KeyID) == "" { + return "", errors.New("插件签名算法或密钥 ID 无效") + } + encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID) + if encodedKey == "" { + return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID) + } + publicKey, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return "", fmt.Errorf("受信任发布者密钥无效: %s", signature.KeyID) + } + signatureBytes, err := base64.StdEncoding.DecodeString(signature.Signature) + if err != nil || !ed25519.Verify(ed25519.PublicKey(publicKey), manifestRaw, signatureBytes) { + return "", errors.New("插件签名校验失败") + } + return PluginSignatureTrusted, nil +} + +func trustedPluginPublisherKey(cfg *config.Config, keyID, pluginID string) string { + // 内置公钥是官方私有插件的固定信任根,不允许被部署配置覆盖。 + if keyID == builtInOpenAITransportPublisherKeyID { + if pluginID != builtInOpenAITransportPluginID { + return "" + } + return builtInOpenAITransportPublisherKeyBase64 + } + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.Plugins.TrustedPublishers[keyID]) +} + +func (i *PluginPackageInstaller) extractArchive(ctx context.Context, archive *zip.Reader, manifest PluginManifest, target string) error { + var extractedBytes int64 + extractLimit := i.cfg.Plugins.MaxUncompressedBytes + for path, expectedHash := range manifest.Files { + if err := ctx.Err(); err != nil { + return err + } + var source *zip.File + for _, file := range archive.File { + if strings.ReplaceAll(file.Name, "\\", "/") == path { + source = file + break + } + } + if source == nil { + return fmt.Errorf("缺少插件文件: %s", path) + } + destination, err := safePluginJoin(target, path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return fmt.Errorf("创建插件文件目录: %w", err) + } + input, err := source.Open() + if err != nil { + return fmt.Errorf("打开插件文件 %s: %w", path, err) + } + hasher := sha256.New() + mode := os.FileMode(0o600) + if path == manifest.Runtimes[manifest.RuntimeKey()].Path { + mode = 0o700 + } + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + _ = input.Close() + return fmt.Errorf("创建插件文件 %s: %w", path, err) + } + remaining := extractLimit - extractedBytes + if remaining < 0 { + remaining = 0 + } + copied, copyErr := io.Copy(io.MultiWriter(output, hasher), io.LimitReader(input, remaining+1)) + extractedBytes += copied + closeOutErr := output.Close() + closeInErr := input.Close() + if copyErr != nil || closeOutErr != nil || closeInErr != nil { + return fmt.Errorf("解压插件文件 %s 失败", path) + } + if extractedBytes > extractLimit { + return errors.New("插件包实际解压体积超过限制") + } + if actual := hex.EncodeToString(hasher.Sum(nil)); actual != expectedHash { + return fmt.Errorf("插件文件哈希不匹配: %s", path) + } + } + return nil +} + +func normalizePluginArchivePath(name string) (string, error) { + normalized := strings.ReplaceAll(strings.TrimSpace(name), "\\", "/") + if normalized == "" || strings.HasPrefix(normalized, "/") || strings.Contains(normalized, "\x00") { + return "", errors.New("插件包包含无效路径") + } + cleaned := filepath.ToSlash(filepath.Clean(normalized)) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || cleaned != normalized { + return "", fmt.Errorf("插件包包含不安全路径: %s", name) + } + return cleaned, nil +} + +func safePluginJoin(root, relative string) (string, error) { + destination := filepath.Join(root, filepath.FromSlash(relative)) + rel, err := filepath.Rel(root, destination) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("插件路径越界: %s", relative) + } + return destination, nil +} + +func readPluginZipFile(file *zip.File, limit int64) ([]byte, error) { + reader, err := file.Open() + if err != nil { + return nil, err + } + defer func() { _ = reader.Close() }() + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("插件文件超过读取限制") + } + return data, nil +} diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go new file mode 100644 index 0000000000..cd0e42de1a --- /dev/null +++ b/backend/internal/service/plugin_package_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"}) + archive := buildTestPluginArchive(t, nil, "") + + installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + + require.NoError(t, err) + assert.Equal(t, PluginStateDisabled, installation.State) + assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus) + assert.FileExists(t, installation.ArtifactPath) + info, statErr := os.Stat(installation.BinaryPath) + require.NoError(t, statErr) + assert.NotZero(t, info.Mode()&0o100) + assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport")) +} + +func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + archive := buildTestPluginArchive(t, nil, "") + + first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + require.NoError(t, err) + second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + require.NoError(t, err) + + assert.NotEqual(t, first.InstallPath, second.InstallPath) + assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath) + assert.FileExists(t, first.BinaryPath) + assert.FileExists(t, second.BinaryPath) +} + +func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), false) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + _, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "不允许安装未签名插件") +} + +func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + cfg := testPluginConfig(t.TempDir(), false) + cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + installation, installErr := installer.Install( + context.Background(), + bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")), + nil, + ) + + require.NoError(t, installErr) + assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) +} + +func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), false) + cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥" + + encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID) + publicKey, err := base64.StdEncoding.DecodeString(encodedKey) + + require.NoError(t, err) + assert.Len(t, publicKey, ed25519.PublicKeySize) + assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey) + assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin")) +} + +func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape")) + + _, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "不安全路径") +} + +func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"}) + + installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) + + require.NoError(t, err) + assert.Equal(t, PluginStateIncompatible, installation.State) + assert.False(t, installation.Compatibility.Compatible) +} + +func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) { + cfg := testPluginConfig(t.TempDir(), true) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + manifest := testPluginManifest(map[string][]byte{ + "bin/plugin": []byte("binary"), + "ui/index.html": []byte(""), + }) + manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64)) + + _, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "哈希不匹配") +} + +func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) { + archiveData := buildTestPluginArchive(t, nil, "") + archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + require.NoError(t, err) + cfg := testPluginConfig(t.TempDir(), true) + cfg.Plugins.MaxUncompressedBytes = 8 + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) + + err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir()) + + require.ErrorContains(t, err, "实际解压体积超过限制") +} + +func testPluginConfig(root string, allowUnsigned bool) *config.Config { + return &config.Config{Plugins: config.PluginConfig{ + DataDir: root, + AllowUnsigned: allowUnsigned, + TrustedPublishers: map[string]string{}, + MaxUploadBytes: 64 * 1024 * 1024, + MaxUncompressedBytes: 128 * 1024 * 1024, + StartTimeoutSeconds: 5, + }} +} + +func testPluginManifest(files map[string][]byte) PluginManifest { + if files == nil { + files = map[string][]byte{ + "bin/plugin": []byte("binary"), + "ui/index.html": []byte(""), + } + } + hashes := make(map[string]string, len(files)) + for path, data := range files { + digest := sha256.Sum256(data) + hashes[path] = hex.EncodeToString(digest[:]) + } + return PluginManifest{ + SchemaVersion: 1, + ID: "com.example.openai-transport", + Name: "测试 OpenAI Transport", + Version: "0.1.0", + Requires: PluginRequirements{ + Sub2API: ">=0.1.170 <0.2.0", + RecommendedSub2APIVersion: "0.1.179", + TestedSub2APIVersions: []string{"0.1.179"}, + PluginProtocol: pluginv1.ProtocolVersion, + TransportAPI: pluginv1.TransportAPIVersion, + UIBridge: pluginv1.UIBridgeVersion, + }, + Capabilities: []PluginCapability{{ + ID: PluginCapabilityOpenAIOAuthOutbound, + Platform: PlatformOpenAI, + AccountType: AccountTypeOAuth, + }}, + Runtimes: map[string]PluginRuntime{ + PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"}, + }, + UI: PluginUIManifest{Entrypoint: "ui/index.html"}, + Files: hashes, + } +} + +func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte { + return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil) +} + +func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte { + return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data}) +} + +func buildPluginArchive( + t *testing.T, + manifest PluginManifest, + privateKey ed25519.PrivateKey, + keyID string, + extra map[string][]byte, +) []byte { + t.Helper() + manifestRaw, err := json.Marshal(manifest) + require.NoError(t, err) + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + writeZipEntry(t, writer, pluginManifestFilename, manifestRaw) + for path := range manifest.Files { + data := []byte("binary") + if path == "ui/index.html" { + data = []byte("") + } + writeZipEntry(t, writer, path, data) + } + for path, data := range extra { + writeZipEntry(t, writer, path, data) + } + if len(privateKey) > 0 { + signatureRaw, marshalErr := json.Marshal(PluginSignature{ + Algorithm: "ed25519", + KeyID: keyID, + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)), + }) + require.NoError(t, marshalErr) + writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw) + } + require.NoError(t, writer.Close()) + return buffer.Bytes() +} + +func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) { + t.Helper() + entry, err := writer.Create(path) + require.NoError(t, err) + _, err = entry.Write(data) + require.NoError(t, err) +} diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go new file mode 100644 index 0000000000..c742569443 --- /dev/null +++ b/backend/internal/service/plugin_runtime.go @@ -0,0 +1,418 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + hclog "github.com/hashicorp/go-hclog" + hcplugin "github.com/hashicorp/go-plugin" +) + +type pluginRuntime struct { + installation *PluginInstallation + client *hcplugin.Client + api pluginv1.TransportPluginClient + inFlight atomic.Int64 + draining atomic.Bool + done chan struct{} + doneOnce sync.Once +} + +func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) { + if installation == nil { + return nil, errors.New("插件安装记录为空") + } + checksum, err := hex.DecodeString(installation.BinarySHA256) + if err != nil || len(checksum) != sha256.Size { + return nil, errors.New("插件二进制哈希无效") + } + cmd := exec.CommandContext(context.WithoutCancel(ctx), installation.BinaryPath) + client := hcplugin.NewClient(&hcplugin.ClientConfig{ + HandshakeConfig: pluginv1.HandshakeConfig, + Plugins: pluginv1.ClientPluginMap(), + Cmd: cmd, + AllowedProtocols: []hcplugin.Protocol{hcplugin.ProtocolGRPC}, + StartTimeout: startTimeout, + SecureConfig: &hcplugin.SecureConfig{ + Checksum: checksum, + Hash: sha256.New(), + }, + Logger: hclog.NewNullLogger(), + SyncStdout: io.Discard, + SyncStderr: io.Discard, + UnixSocketConfig: &hcplugin.UnixSocketConfig{TempDir: socketDir}, + SkipHostEnv: true, + }) + rpcClient, err := client.Client() + if err != nil { + client.Kill() + return nil, fmt.Errorf("启动插件进程: %w", err) + } + dispensed, err := rpcClient.Dispense(pluginv1.TransportPluginName) + if err != nil { + client.Kill() + return nil, fmt.Errorf("获取插件传输能力: %w", err) + } + api, ok := dispensed.(pluginv1.TransportPluginClient) + if !ok { + client.Kill() + return nil, errors.New("插件未实现传输 gRPC 客户端") + } + runtime := &pluginRuntime{ + installation: installation, + client: client, + api: api, + done: make(chan struct{}), + } + infoCtx, cancel := context.WithTimeout(ctx, startTimeout) + defer cancel() + info, err := api.GetInfo(infoCtx, &pluginv1.GetInfoRequest{}) + if err != nil { + runtime.kill() + return nil, fmt.Errorf("读取插件信息: %w", err) + } + if info.PluginId != installation.PluginKey || info.PluginVersion != installation.Version || + info.ProtocolVersion != pluginv1.ProtocolVersion || info.TransportApiVersion != pluginv1.TransportAPIVersion { + runtime.kill() + return nil, errors.New("插件运行时信息与已校验清单不一致") + } + health, err := api.Health(infoCtx, &pluginv1.HealthRequest{}) + if err != nil || !health.Healthy { + runtime.kill() + if err != nil { + return nil, fmt.Errorf("插件健康检查失败: %w", err) + } + return nil, fmt.Errorf("插件不健康: %s", health.Message) + } + return runtime, nil +} + +func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error { + _, err := r.validateAndApplyNormalizedConfig(ctx, configJSON) + return err +} + +func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, configJSON []byte) ([]byte, error) { + validation, err := r.api.ValidateConfig(ctx, &pluginv1.ValidateConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("插件配置校验失败: %w", err) + } + if !validation.Valid { + return nil, fmt.Errorf("插件配置无效: %s", validation.Message) + } + if len(validation.NormalizedConfigJson) > 0 { + configJSON = validation.NormalizedConfigJson + } + if len(configJSON) == 0 || len(configJSON) > pluginConfigMaxBytes || !json.Valid(configJSON) { + return nil, errors.New("插件返回的规范化配置不是有效且大小受限的 JSON") + } + var normalized any + if err := json.Unmarshal(configJSON, &normalized); err != nil { + return nil, fmt.Errorf("解析插件规范化配置: %w", err) + } + if normalized == nil { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } + if _, ok := normalized.(map[string]any); !ok { + return nil, errors.New("插件返回的规范化配置根节点必须是对象") + } + configJSON, err = json.Marshal(normalized) + if err != nil { + return nil, fmt.Errorf("序列化插件规范化配置: %w", err) + } + applied, err := r.api.ApplyConfig(ctx, &pluginv1.ApplyConfigRequest{ConfigJson: configJSON}) + if err != nil { + return nil, fmt.Errorf("应用插件配置失败: %w", err) + } + if !applied.Applied { + return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message) + } + return configJSON, nil +} + +func (r *pluginRuntime) checkHealth(ctx context.Context) error { + if r == nil || r.api == nil || r.client == nil || r.client.Exited() { + return errors.New("插件进程已退出") + } + health, err := r.api.Health(ctx, &pluginv1.HealthRequest{}) + if err != nil { + return fmt.Errorf("插件健康检查失败: %w", err) + } + if health == nil || !health.Healthy { + message := "插件报告不健康" + if health != nil && strings.TrimSpace(health.Message) != "" { + message = "插件不健康: " + health.Message + } + return errors.New(message) + } + return nil +} + +func (r *pluginRuntime) beginRequest() bool { + if r == nil || r.draining.Load() { + return false + } + r.inFlight.Add(1) + if r.draining.Load() { + r.finishRequest() + return false + } + return true +} + +func (r *pluginRuntime) finishRequest() { + if r.inFlight.Add(-1) == 0 && r.draining.Load() { + r.doneOnce.Do(func() { close(r.done) }) + } +} + +func (r *pluginRuntime) drain(timeout time.Duration) { + if r == nil { + return + } + r.draining.Store(true) + if r.inFlight.Load() == 0 { + r.doneOnce.Do(func() { close(r.done) }) + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-r.done: + case <-timer.C: + } + r.kill() +} + +func (r *pluginRuntime) kill() { + if r != nil && r.client != nil { + r.client.Kill() + } +} + +func (r *pluginRuntime) roundTrip(ctx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { + if request == nil || request.URL == nil || account == nil { + return nil, errors.New("插件出站请求参数不完整") + } + streamCtx, cancel := context.WithCancel(ctx) + stream, err := r.api.Forward(streamCtx) + if err != nil { + cancel() + return nil, normalizePluginRPCError(ctx, "创建插件转发流", err, false) + } + requestID := strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.FormatInt(account.ID, 36) + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_Start{Start: &pluginv1.ForwardRequestStart{ + RequestId: requestID, + Method: request.Method, + Url: request.URL.String(), + Host: request.Host, + Headers: headersToPlugin(request.Header), + ProxyUrl: proxyURL, + AccountId: account.ID, + AccountConcurrency: int32(account.Concurrency), + Platform: account.Platform, + AccountType: account.Type, + ContentLength: request.ContentLength, + HasBody: request.Body != nil && request.Body != http.NoBody, + }}}); err != nil { + cancel() + // gRPC Send 返回错误时无法证明服务端没有收到元数据,必须禁止自动重放。 + return nil, normalizePluginRPCError(ctx, "发送插件请求元数据", err, true) + } + sendErr := make(chan error, 1) + go func() { + err := sendPluginRequestBody(stream, request.Body) + sendErr <- err + if err != nil { + cancel() + } + }() + + first, err := stream.Recv() + if err != nil { + cancel() + select { + case bodyErr := <-sendErr: + if bodyErr != nil { + return nil, normalizePluginRPCError(ctx, "发送插件请求体", bodyErr, true) + } + default: + } + return nil, normalizePluginRPCError(ctx, "接收插件响应头", err, true) + } + if frameError := first.GetError(); frameError != nil { + cancel() + return nil, &PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent} + } + start := first.GetStart() + if start == nil || start.StatusCode < 100 || start.StatusCode > 599 { + cancel() + return nil, &PluginTransportError{ + Code: "PLUGIN_INVALID_RESPONSE", + Message: "插件未返回有效的 HTTP 响应头", + RequestSent: true, + } + } + pipeReader, pipeWriter := io.Pipe() + body := &pluginResponseBody{ + reader: pipeReader, + cancel: cancel, + done: r.finishRequest, + } + go receivePluginResponseBody(stream, pipeWriter, sendErr) + return &http.Response{ + Status: start.Status, + StatusCode: int(start.StatusCode), + Proto: start.Protocol, + ProtoMajor: int(start.ProtocolMajor), + ProtoMinor: int(start.ProtocolMinor), + Header: headersFromPlugin(start.Headers), + Body: body, + ContentLength: start.ContentLength, + Request: request, + }, nil +} + +type PluginTransportError struct { + Code string + Message string + RequestSent bool +} + +func (e *PluginTransportError) Error() string { + if e == nil { + return "插件传输失败" + } + code := strings.Map(func(value rune) rune { + if value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || value == '_' || value == '-' || value == '.' { + return value + } + return -1 + }, e.Code) + if len(code) > 64 { + code = code[:64] + } + return fmt.Sprintf("插件传输失败 [%s]: %s", code, sanitizeUpstreamErrorMessage(e.Message)) +} + +func normalizePluginRPCError(ctx context.Context, operation string, err error, requestMayHaveBeenSent bool) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + return &PluginTransportError{ + Code: "PLUGIN_RPC_ERROR", + Message: fmt.Sprintf("%s: %v", operation, err), + RequestSent: requestMayHaveBeenSent, + } +} + +func sendPluginRequestBody(stream pluginv1.TransportPlugin_ForwardClient, body io.ReadCloser) error { + if body != nil { + defer func() { _ = body.Close() }() + buffer := make([]byte, 32*1024) + for { + read, err := body.Read(buffer) + if read > 0 { + chunk := append([]byte(nil), buffer[:read]...) + if sendErr := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyChunk{BodyChunk: chunk}}); sendErr != nil { + return sendErr + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + } + if err := stream.Send(&pluginv1.ForwardRequest{Frame: &pluginv1.ForwardRequest_BodyEnd{BodyEnd: true}}); err != nil { + return err + } + return stream.CloseSend() +} + +func receivePluginResponseBody(stream pluginv1.TransportPlugin_ForwardClient, writer *io.PipeWriter, sendErr <-chan error) { + defer func() { _ = writer.Close() }() + for { + frame, err := stream.Recv() + if err == io.EOF { + return + } + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "接收插件响应体", err, true)) + return + } + if chunk := frame.GetBodyChunk(); len(chunk) > 0 { + if _, err := writer.Write(chunk); err != nil { + return + } + continue + } + if frame.GetEnd() != nil { + select { + case err := <-sendErr: + if err != nil { + _ = writer.CloseWithError(normalizePluginRPCError(stream.Context(), "发送插件请求体", err, true)) + } + default: + } + return + } + if frameError := frame.GetError(); frameError != nil { + _ = writer.CloseWithError(&PluginTransportError{Code: frameError.Code, Message: frameError.Message, RequestSent: frameError.RequestSent}) + return + } + } +} + +type pluginResponseBody struct { + reader *io.PipeReader + cancel context.CancelFunc + done func() + once sync.Once +} + +func (b *pluginResponseBody) Read(data []byte) (int, error) { + return b.reader.Read(data) +} + +func (b *pluginResponseBody) Close() error { + var err error + b.once.Do(func() { + b.cancel() + err = b.reader.Close() + b.done() + }) + return err +} + +func headersToPlugin(headers http.Header) map[string]*pluginv1.HeaderValues { + out := make(map[string]*pluginv1.HeaderValues, len(headers)) + for key, values := range headers { + out[key] = &pluginv1.HeaderValues{Values: append([]string(nil), values...)} + } + return out +} + +func headersFromPlugin(headers map[string]*pluginv1.HeaderValues) http.Header { + out := make(http.Header, len(headers)) + for key, values := range headers { + if values != nil { + out[key] = append([]string(nil), values.Values...) + } + } + return out +} diff --git a/backend/internal/service/plugin_runtime_integration_test.go b/backend/internal/service/plugin_runtime_integration_test.go new file mode 100644 index 0000000000..42db798d37 --- /dev/null +++ b/backend/internal/service/plugin_runtime_integration_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type pluginFailingRequestBody struct{} + +func (pluginFailingRequestBody) Read([]byte) (int, error) { + return 0, errors.New("测试请求体读取失败") +} +func (pluginFailingRequestBody) Close() error { return nil } + +func TestPluginRuntimeIntegration(t *testing.T) { + packagePath := os.Getenv("SUB2API_TEST_PLUGIN_PACKAGE") + if packagePath == "" { + t.Skip("未提供 SUB2API_TEST_PLUGIN_PACKAGE,跳过本地插件进程集成测试") + } + packageFile, err := os.Open(packagePath) + require.NoError(t, err) + defer func() { _ = packageFile.Close() }() + + root := t.TempDir() + cfg := testPluginConfig(root, false) + installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"}) + installation, err := installer.Install(context.Background(), packageFile, nil) + require.NoError(t, err) + assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) + for _, relative := range []string{ + "ui/index.html", + "ui/assets/styles.css", + "ui/assets/bridge-v1.js", + "ui/assets/app.js", + } { + assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative))) + } + + runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime")) + require.NoError(t, err) + defer runtime.kill() + require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{ + "request_timeout_seconds":30, + "response_header_timeout_seconds":10, + "idle_connection_timeout_seconds":30, + "max_idle_connections":10, + "max_idle_connections_per_host":5, + "enable_http2":true, + "tls_min_version":"1.2", + "proxy_mode":"disabled", + "extra_headers":{"X-Plugin-Test":"enabled"} + }`))) + + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, readErr := io.ReadAll(request.Body) + require.NoError(t, readErr) + assert.Equal(t, "payload", string(body)) + assert.Equal(t, "enabled", request.Header.Get("X-Plugin-Test")) + assert.Equal(t, int64(len("payload")), request.ContentLength) + assert.Empty(t, request.TransferEncoding) + writer.Header().Set("X-Upstream", "plugin-runtime") + writer.WriteHeader(http.StatusCreated) + _, _ = writer.Write([]byte("response-through-plugin")) + })) + defer upstream.Close() + + request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, bytes.NewBufferString("payload")) + require.NoError(t, err) + request.Header.Set("Content-Type", "application/json") + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + require.True(t, runtime.beginRequest()) + response, err := runtime.roundTrip(context.Background(), request, "", account) + if err != nil { + runtime.finishRequest() + } + require.NoError(t, err) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.Equal(t, "plugin-runtime", response.Header.Get("X-Upstream")) + assert.Equal(t, "response-through-plugin", string(body)) + assert.Equal(t, int64(len("response-through-plugin")), response.ContentLength) + require.NoError(t, response.Body.Close()) + + failingRequest, err := http.NewRequestWithContext(context.Background(), http.MethodPost, upstream.URL, pluginFailingRequestBody{}) + require.NoError(t, err) + failingRequest.ContentLength = -1 + require.True(t, runtime.beginRequest()) + started := time.Now() + _, err = runtime.roundTrip(context.Background(), failingRequest, "", account) + runtime.finishRequest() + require.Error(t, err) + assert.Less(t, time.Since(started), 2*time.Second) +} diff --git a/backend/internal/service/plugin_security_regression_test.go b/backend/internal/service/plugin_security_regression_test.go new file mode 100644 index 0000000000..ae45fe4420 --- /dev/null +++ b/backend/internal/service/plugin_security_regression_test.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type pluginTokenRepository struct { + PluginRepository + installation *PluginInstallation + listErr error +} + +func (r *pluginTokenRepository) List(context.Context) ([]*PluginInstallation, error) { + if r.listErr != nil { + return nil, r.listErr + } + if r.installation == nil { + return nil, nil + } + copy := *r.installation + return []*PluginInstallation{©}, nil +} + +func (r *pluginTokenRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + if r.installation == nil { + return nil, errors.New("插件不存在") + } + copy := *r.installation + return ©, nil +} + +type pluginTokenEncryptor struct{} + +func (pluginTokenEncryptor) Encrypt(plaintext string) (string, error) { + return "ENC:" + plaintext, nil +} + +func (pluginTokenEncryptor) Decrypt(ciphertext string) (string, error) { + if !strings.HasPrefix(ciphertext, "ENC:") { + return "", errors.New("密文无效") + } + return strings.TrimPrefix(ciphertext, "ENC:"), nil +} + +func TestPluginUIAssetTokenCanBeResolvedByAnotherInstance(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + first := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + second := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + token, expires, err := first.CreateUIAssetToken(context.Background(), 42, 30*time.Minute) + require.NoError(t, err) + require.WithinDuration(t, time.Now().Add(30*time.Minute), expires, time.Second) + + pluginID, err := second.ResolveUIAssetToken(token) + require.NoError(t, err) + require.Equal(t, int64(42), pluginID) + + decoded, err := base64.RawURLEncoding.DecodeString(token) + require.NoError(t, err) + decoded[len(decoded)-1] ^= 1 + _, err = second.ResolveUIAssetToken(base64.RawURLEncoding.EncodeToString(decoded)) + require.Error(t, err) +} + +func TestPluginUIAssetTokenRejectsOtherEncryptedPayloads(t *testing.T) { + repo := &pluginTokenRepository{installation: &PluginInstallation{ID: 42}} + manager := &PluginManager{repo: repo, encryptor: pluginTokenEncryptor{}} + + encrypted, err := manager.encryptor.Encrypt(`{"version":1,"plugin_id":42,"expires":4102444800}`) + require.NoError(t, err) + token := base64.RawURLEncoding.EncodeToString([]byte(encrypted)) + + _, err = manager.ResolveUIAssetToken(token) + require.ErrorContains(t, err, "会话无效") +} + +func TestPluginReconcileFailsClosedWhenDesiredStateCannotBeRead(t *testing.T) { + manager := &PluginManager{ + repo: &pluginTokenRepository{listErr: errors.New("数据库不可用")}, + runtimes: make(map[int64]*pluginRuntime), + localInstallations: make(map[int64]*PluginInstallation), + } + + err := manager.reconcileOnce(context.Background()) + require.ErrorContains(t, err, "读取插件启用状态") + require.True(t, manager.ShouldRouteOpenAIOAuth(&Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + })) + + request, requestErr := http.NewRequestWithContext(context.Background(), http.MethodPost, "https://example.com/v1/responses", nil) + require.NoError(t, requestErr) + _, handled, routeErr := manager.RoundTripOpenAIOAuth(context.Background(), request, "", &Account{ + ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + }) + require.True(t, handled) + require.ErrorContains(t, routeErr, "插件不可用") +} + +type normalizingPluginClient struct { + pluginv1.TransportPluginClient + normalized []byte + applied []byte +} + +type pluginConfigRepository struct { + PluginRepository + installation *PluginInstallation + encrypted string +} + +func (r *pluginConfigRepository) GetByID(context.Context, int64) (*PluginInstallation, error) { + copy := *r.installation + return ©, nil +} + +func (r *pluginConfigRepository) UpdateConfig(_ context.Context, _ int64, encrypted, expectedBinarySHA256 string) error { + if expectedBinarySHA256 != r.installation.BinarySHA256 { + return ErrPluginStateChanged + } + r.encrypted = encrypted + return nil +} + +func (c *normalizingPluginClient) ValidateConfig(context.Context, *pluginv1.ValidateConfigRequest, ...grpc.CallOption) (*pluginv1.ValidateConfigResponse, error) { + return &pluginv1.ValidateConfigResponse{Valid: true, NormalizedConfigJson: c.normalized}, nil +} + +func (c *normalizingPluginClient) ApplyConfig(_ context.Context, request *pluginv1.ApplyConfigRequest, _ ...grpc.CallOption) (*pluginv1.ApplyConfigResponse, error) { + c.applied = append([]byte(nil), request.ConfigJson...) + return &pluginv1.ApplyConfigResponse{Applied: true}, nil +} + +func TestPluginRuntimeReturnsAndAppliesNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"z":2,"a":1}`)} + runtime := &pluginRuntime{api: client} + + normalized, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{"input":true}`)) + require.NoError(t, err) + require.JSONEq(t, `{"a":1,"z":2}`, string(normalized)) + require.Equal(t, normalized, client.applied) +} + +func TestPluginRuntimeRejectsInvalidNormalizedConfig(t *testing.T) { + client := &normalizingPluginClient{normalized: []byte(`{"broken"`)} + runtime := &pluginRuntime{api: client} + + _, err := runtime.validateAndApplyNormalizedConfig(context.Background(), []byte(`{}`)) + require.ErrorContains(t, err, "规范化配置") + require.Empty(t, client.applied) +} + +func TestPluginManagerPersistsPluginNormalizedConfig(t *testing.T) { + installation := &PluginInstallation{ID: 9, BinarySHA256: strings.Repeat("a", 64)} + repo := &pluginConfigRepository{installation: installation} + client := &normalizingPluginClient{normalized: []byte(`{"timeout":30,"enabled":true}`)} + manager := &PluginManager{ + repo: repo, encryptor: pluginTokenEncryptor{}, + runtimes: map[int64]*pluginRuntime{9: {installation: installation, api: client}}, + } + + saved, err := manager.SaveConfig(context.Background(), 9, []byte(`{"enabled":false}`)) + require.NoError(t, err) + require.JSONEq(t, `{"enabled":true,"timeout":30}`, string(saved)) + plaintext, err := (pluginTokenEncryptor{}).Decrypt(repo.encrypted) + require.NoError(t, err) + require.JSONEq(t, string(saved), plaintext) +} + +func TestPluginRequestSentErrorDoesNotFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 7, Name: "oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth} + transportErr := &PluginTransportError{Code: "UPSTREAM_EOF", Message: "eof", RequestSent: true} + + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, transportErr, true) + + require.Same(t, transportErr, result) + var failover *UpstreamFailoverError + require.False(t, errors.As(result, &failover)) +} + +func TestPluginRPCAmbiguityPreventsReplayAfterMetadataDelivery(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "接收插件响应头", errors.New("连接已断开"), true) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.True(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + require.Same(t, err, result) +} + +func TestPluginRPCFailureBeforeStreamCreationAllowsFailover(t *testing.T) { + err := normalizePluginRPCError(context.Background(), "创建插件转发流", errors.New("连接失败"), false) + var transportErr *PluginTransportError + require.ErrorAs(t, err, &transportErr) + require.False(t, transportErr.RequestSent) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + result := (&OpenAIGatewayService{}).handleOpenAIUpstreamTransportError(context.Background(), c, account, err, true) + var failover *UpstreamFailoverError + require.ErrorAs(t, result, &failover) +} + +func TestNormalizePluginRPCErrorPreservesCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := normalizePluginRPCError(ctx, "接收响应", errors.New("rpc error: code = Canceled"), true) + require.ErrorIs(t, err, context.Canceled) +} + +func TestPluginStartingStateUsesBoundedCrashRecoveryWindow(t *testing.T) { + manager := &PluginManager{cfg: &config.Config{Plugins: config.PluginConfig{StartTimeoutSeconds: 15}}} + + require.False(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-30 * time.Second)})) + require.True(t, manager.startingStateExpired(&PluginInstallation{UpdatedAt: time.Now().Add(-2 * time.Minute)})) +} diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f7733ae001..6ae24dd39e 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -596,7 +596,7 @@ func (s *PricingService) useFallbackPricing() error { } pricingFile := s.getPricingFilePath() - if err := os.WriteFile(pricingFile, data, 0644); err != nil { + if err := os.WriteFile(pricingFile, data, 0644); err != nil { //nolint:gosec // G703: 路径为配置的数据目录 + 硬编码文件名,非请求输入 logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err) } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 2c42b810b6..0a52925cd9 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -79,6 +79,10 @@ const ( var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`) +var openCodeGoUsageLimitResetPattern = regexp.MustCompile(`(?i)\bresets\s+in\s+`) + +var openCodeGoUsageLimitDurationPartPattern = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)\b`) + const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 @@ -1092,6 +1096,9 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head // 单影子场景直接变成无可用账号(外审第8轮 P1)。整段跳过;影子的 codex_* 仅由 account_usage 的 // QueryUsage→persistOpenAICodexProbeSnapshot 维护,枯竭由调度守卫处理。 if account.IsShadow() { + if account.ParentAccountID != nil { + notifyOpenAIAutoReset(*account.ParentAccountID) + } return } // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, @@ -1105,6 +1112,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head if account.Platform == PlatformOpenAI { persistOpenAI429PlanType(ctx, s.accountRepo, account, responseBody) s.persistOpenAICodexSnapshot(ctx, account, headers) + notifyOpenAIAutoReset(account.ID) if resetAt := s.calculateOpenAI429ResetTime(headers); resetAt != nil { s.notifyAccountSchedulingBlocked(account, *resetAt, "429") if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { @@ -1620,10 +1628,12 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou } if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil { slog.Warn("openai_codex_snapshot_persist_failed", "account_id", account.ID, "error", err) + return } + notifyOpenAIAutoReset(account.ID) } -// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 +// parseOpenAIRateLimitResetTime 解析 OpenAI 兼容格式的 429 响应,返回重置时间的 Unix 时间戳 // OpenAI 的 usage_limit_reached 错误格式: // // { @@ -1645,9 +1655,9 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { return nil } - // 检查是否为 usage_limit_reached 或 rate_limit_exceeded 类型 + // 检查是否为已知的账号用量限制类型。 errType, _ := errObj["type"].(string) - if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" { + if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" && errType != "GoUsageLimitError" { return nil } @@ -1674,9 +1684,76 @@ func parseOpenAIRateLimitResetTime(body []byte) *int64 { } } + // OpenCode Go subscriptions expose the reset only in a human-readable message, + // for example: "Weekly usage limit reached. Resets in 2 days." + if errType == "GoUsageLimitError" { + message, _ := errObj["message"].(string) + if resetAfter := parseOpenCodeGoUsageLimitResetDuration(message); resetAfter > 0 { + ts := time.Now().Add(resetAfter).Unix() + return &ts + } + } + return nil } +func parseOpenCodeGoUsageLimitResetDuration(message string) time.Duration { + resetPrefix := openCodeGoUsageLimitResetPattern.FindStringIndex(message) + if resetPrefix == nil { + return 0 + } + + remainder := message[resetPrefix[1]:] + var total time.Duration + for { + remainder = strings.TrimSpace(remainder) + matches := openCodeGoUsageLimitDurationPartPattern.FindStringSubmatchIndex(remainder) + if matches == nil { + break + } + + value, err := strconv.ParseFloat(remainder[matches[2]:matches[3]], 64) + if err != nil || value <= 0 { + return 0 + } + + unit := openCodeGoUsageLimitDurationUnit(remainder[matches[4]:matches[5]]) + if unit <= 0 { + return 0 + } + + const maxDuration = time.Duration(1<<63 - 1) + if value >= float64(maxDuration)/float64(unit) { + return 0 + } + part := time.Duration(value * float64(unit)) + if part <= 0 || total > maxDuration-part { + return 0 + } + total += part + remainder = remainder[matches[1]:] + } + + return total +} + +func openCodeGoUsageLimitDurationUnit(raw string) time.Duration { + switch strings.ToLower(raw) { + case "s", "sec", "secs", "second", "seconds": + return time.Second + case "m", "min", "mins", "minute", "minutes": + return time.Minute + case "h", "hr", "hrs", "hour", "hours": + return time.Hour + case "d", "day", "days": + return 24 * time.Hour + case "w", "week", "weeks": + return 7 * 24 * time.Hour + default: + return 0 + } +} + func parseOpenAIRateLimitPlanType(body []byte) string { var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 4b619e15b2..8969f7e622 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -117,6 +117,49 @@ func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { } } +func TestParseOpenAIRateLimitResetTime_OpenCodeGoUsageLimit(t *testing.T) { + tests := []struct { + name string + body string + want time.Duration + }{ + { + name: "days", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 2 days."}}`, + want: 48 * time.Hour, + }, + { + name: "hours", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"Weekly usage limit reached. Resets in 18 hours."}}`, + want: 18 * time.Hour, + }, + { + name: "hours and minutes", + body: `{"type":"error","error":{"type":"GoUsageLimitError","message":"5-hour usage limit reached. Resets in 4hr 59min."}}`, + want: 4*time.Hour + 59*time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := time.Now() + resetAt := parseOpenAIRateLimitResetTime([]byte(tt.body)) + after := time.Now() + + require.NotNil(t, resetAt) + actual := time.Unix(*resetAt, 0) + require.False(t, actual.Before(before.Add(tt.want).Truncate(time.Second))) + require.False(t, actual.After(after.Add(tt.want))) + }) + } +} + +func TestParseOpenAIRateLimitResetTime_DoesNotParseUnknownErrorMessage(t *testing.T) { + body := []byte(`{"error":{"type":"rate_limit_error","message":"Resets in 2 days."}}`) + + require.Nil(t, parseOpenAIRateLimitResetTime(body)) +} + func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) { svc := &RateLimitService{} diff --git a/backend/internal/service/service_tier_billing.go b/backend/internal/service/service_tier_billing.go new file mode 100644 index 0000000000..74cce9146e --- /dev/null +++ b/backend/internal/service/service_tier_billing.go @@ -0,0 +1,107 @@ +package service + +import ( + "log/slog" + "strings" +) + +// ServiceTierBillingResolution describes how the billable service tier of one +// request was settled between the tier the client asked for and the tier the +// upstream reports having used. +type ServiceTierBillingResolution struct { + Requested string // tier carried by the request sent upstream ("" when none) + Observed string // tier declared by the upstream response ("" when none) + Billing string // tier used for billing and the usage log + Downgraded bool // Billing is cheaper than Requested +} + +// ResolveBillingServiceTier picks the tier to bill. The upstream declaration is +// trusted only to lower the bill: a request served below the tier it asked for +// (OpenAI "priority" answered with service_tier "default", Anthropic speed=fast +// answered with usage.speed "standard") is billed at the cheaper tier. A response +// that claims a more expensive tier, an unknown one, or none at all leaves the +// requested tier in place, which is also the behaviour for upstreams that never +// declare a tier. +func ResolveBillingServiceTier(requested, observed string) ServiceTierBillingResolution { + requested = normalizeBillingServiceTier(requested) + observed = normalizeBillingServiceTier(observed) + resolution := ServiceTierBillingResolution{Requested: requested, Observed: observed, Billing: requested} + if observed == "" || observed == requested { + return resolution + } + observedRank, known := serviceTierCostRank(observed) + if !known { + return resolution + } + requestedRank, _ := serviceTierCostRank(requested) + if observedRank >= requestedRank { + return resolution + } + resolution.Billing = observed + resolution.Downgraded = true + return resolution +} + +// serviceTierCostRank orders tiers by their cost relative to the base rate, so a +// lower rank is always cheaper. Unknown tiers rank as the base rate and report +// known=false so callers can refuse to act on them. +func serviceTierCostRank(tier string) (rank int, known bool) { + switch normalizeBillingServiceTier(tier) { + case "flex": + return 0, true + case "", "default", "standard", "auto", "scale": + return 1, true + case "priority", "fast": + return 2, true + default: + return 1, false + } +} + +// ApplyOpenAIServiceTierBillingResolution lowers result.ServiceTier to the tier +// the upstream reports having used, so cost calculation and the usage log share +// one billable tier. The returned resolution is meant for the audit log. +func ApplyOpenAIServiceTierBillingResolution(result *OpenAIForwardResult) ServiceTierBillingResolution { + if result == nil { + return ServiceTierBillingResolution{} + } + resolution := ResolveBillingServiceTier(optionalStringValue(result.ServiceTier), result.UpstreamResponseServiceTier) + if resolution.Downgraded { + billing := resolution.Billing + result.ServiceTier = &billing + } + return resolution +} + +// ApplyForwardServiceTierBillingResolution is the ForwardResult counterpart of +// ApplyOpenAIServiceTierBillingResolution. +func ApplyForwardServiceTierBillingResolution(result *ForwardResult) ServiceTierBillingResolution { + if result == nil { + return ServiceTierBillingResolution{} + } + resolution := ResolveBillingServiceTier(optionalStringValue(result.ServiceTier), result.UpstreamResponseServiceTier) + if resolution.Downgraded { + billing := resolution.Billing + result.ServiceTier = &billing + } + return resolution +} + +// logServiceTierBillingDowngrade leaves an audit trail for every request billed +// below the tier it asked for; unchanged tiers are not logged. +func logServiceTierBillingDowngrade(component string, account *Account, requestID string, resolution ServiceTierBillingResolution) { + if !resolution.Downgraded { + return + } + attrs := []any{ + "component", component, + "request_id", strings.TrimSpace(requestID), + "requested_tier", resolution.Requested, + "response_tier", resolution.Observed, + "billed_tier", resolution.Billing, + } + if account != nil { + attrs = append(attrs, "platform", account.Platform, "account_id", account.ID) + } + slog.Info("billing.service_tier_downgraded", attrs...) +} diff --git a/backend/internal/service/service_tier_billing_test.go b/backend/internal/service/service_tier_billing_test.go new file mode 100644 index 0000000000..fc8242fa2b --- /dev/null +++ b/backend/internal/service/service_tier_billing_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveBillingServiceTier(t *testing.T) { + tests := []struct { + name string + requested string + observed string + billing string + downgraded bool + }{ + {name: "openai priority served as default", requested: "priority", observed: "default", billing: "default", downgraded: true}, + {name: "anthropic fast served as standard", requested: "fast", observed: "standard", billing: "standard", downgraded: true}, + {name: "priority honoured", requested: "priority", observed: "priority", billing: "priority"}, + {name: "no declaration keeps request", requested: "priority", observed: "", billing: "priority"}, + {name: "no request no declaration", requested: "", observed: "", billing: ""}, + {name: "response never raises the tier", requested: "", observed: "priority", billing: ""}, + {name: "flex never raised to default", requested: "flex", observed: "default", billing: "flex"}, + {name: "default echoed for untiered request", requested: "", observed: "default", billing: ""}, + {name: "unknown response tier ignored", requested: "priority", observed: "turbo", billing: "priority"}, + {name: "case and whitespace normalised", requested: " Priority ", observed: "DEFAULT", billing: "default", downgraded: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveBillingServiceTier(tt.requested, tt.observed) + require.Equal(t, tt.billing, got.Billing) + require.Equal(t, tt.downgraded, got.Downgraded) + }) + } +} + +func TestApplyServiceTierBillingResolutionOnlyRewritesDowngrades(t *testing.T) { + t.Run("openai downgrade rewrites tier", func(t *testing.T) { + requested := "priority" + result := &OpenAIForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "default"} + resolution := ApplyOpenAIServiceTierBillingResolution(result) + require.True(t, resolution.Downgraded) + require.NotNil(t, result.ServiceTier) + require.Equal(t, "default", *result.ServiceTier) + }) + + t.Run("openai honoured tier keeps pointer", func(t *testing.T) { + requested := "priority" + result := &OpenAIForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "priority"} + require.False(t, ApplyOpenAIServiceTierBillingResolution(result).Downgraded) + require.Same(t, &requested, result.ServiceTier) + }) + + t.Run("openai untiered request stays nil", func(t *testing.T) { + result := &OpenAIForwardResult{UpstreamResponseServiceTier: "priority"} + require.False(t, ApplyOpenAIServiceTierBillingResolution(result).Downgraded) + require.Nil(t, result.ServiceTier) + }) + + t.Run("anthropic standard speed rewrites fast", func(t *testing.T) { + requested := "fast" + result := &ForwardResult{ServiceTier: &requested, UpstreamResponseServiceTier: "standard"} + require.True(t, ApplyForwardServiceTierBillingResolution(result).Downgraded) + require.Equal(t, "standard", *result.ServiceTier) + }) + + t.Run("nil results are ignored", func(t *testing.T) { + require.False(t, ApplyOpenAIServiceTierBillingResolution(nil).Downgraded) + require.False(t, ApplyForwardServiceTierBillingResolution(nil).Downgraded) + }) +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 7787e8810f..a1a0c218e3 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -201,9 +201,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyAvailableChannelsEnabled: "false", // Model plaza feature (default disabled; opt-in, public unless require_auth) - SettingKeyModelPlazaEnabled: "false", - SettingKeyModelPlazaRequireAuth: "false", - SettingKeyModelPlazaDescription: "", + SettingKeyModelPlazaEnabled: "false", + SettingKeyModelPlazaRequireAuth: "false", + SettingKeyModelPlazaDescription: "", + SettingKeyPluginManagementEnabled: "false", // Affiliate (邀请返利) feature (default disabled; opt-in) SettingKeyAffiliateEnabled: "false", @@ -820,6 +821,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true" result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true" result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription] + result.PluginManagementEnabled = settings[SettingKeyPluginManagementEnabled] == "true" // Affiliate (邀请返利) feature (default: disabled; strict true) result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 81d74aff0d..7072dd7c03 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -235,6 +235,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, + SettingKeyPluginManagementEnabled, SettingKeyAffiliateEnabled, SettingKeyRiskControlEnabled, SettingKeyAllowUserViewErrorRequests, @@ -360,8 +361,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", - ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", + ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", + ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", + PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true", AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", @@ -620,6 +622,7 @@ type PublicSettingsInjectionPayload struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` @@ -700,6 +703,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, + PluginManagementEnabled: settings.PluginManagementEnabled, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests, diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 65e28ff6ee..7a421fde75 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -435,6 +435,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled) updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth) updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription + updates[SettingKeyPluginManagementEnabled] = strconv.FormatBool(settings.PluginManagementEnabled) // Affiliate (邀请返利) feature switch updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 25d48e7dca..25d2b313e1 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -212,9 +212,10 @@ type SystemSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - ModelPlazaDescription string `json:"model_plaza_description"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaDescription string `json:"model_plaza_description"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // Claude Code version check MinClaudeCodeVersion string @@ -390,8 +391,9 @@ type PublicSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` // Model Plaza feature (public group/model pricing showcase) - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` // Affiliate (邀请返利) feature toggle AffiliateEnabled bool `json:"affiliate_enabled"` diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 164b9fe816..364d1dc16c 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -594,12 +594,13 @@ func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account } defer func() { _ = resp.Body.Close() }() - body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } - if int64(len(body)) > upstreamModelsBodyLimit { - return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit)) + if int64(len(body)) > bodyLimit { + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { @@ -838,6 +839,9 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con } func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + if account.IsOpenAIOAuth() { + return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) + } if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -870,6 +874,68 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex return req, nil } +// buildOpenAIOAuthUpstreamModelsRequest uses ChatGPT's Codex model manifest. +// OAuth subscriptions do not expose the public Platform API /v1/models endpoint, +// so treating them like API-key accounts makes the admin sync button fail locally. +func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Failed to resolve OpenAI account credentials", err) + } + if !credentialAccount.IsOpenAIOAuth() { + return nil, newUpstreamModelSyncUnsupportedError( + fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", credentialAccount.Type), nil, + ) + } + + modelsURL, err := buildCodexModelsManifestURL( + chatgptCodexModelsURL, + false, + CodexCanonicalClientVersion(), + ) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list URL", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list request", err) + } + + if credentialAccount.IsOpenAIAgentIdentity() { + authHeaders, authErr := buildAgentIdentityAuthenticationHeaders( + ctx, + s.accountRepo, + s.agentIdentityWS, + &s.agentIdentityTaskMu, + credentialAccount, + ) + if authErr != nil { + return nil, newUpstreamModelSyncUpstreamError("Failed to build OpenAI Agent Identity authentication", authErr) + } + for key, values := range authHeaders { + for _, value := range values { + req.Header.Add(key, value) + } + } + } else { + accessToken := strings.TrimSpace(credentialAccount.GetOpenAIAccessToken()) + if accessToken == "" { + return nil, newUpstreamModelSyncConfigError("No OpenAI access token is available", nil) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Originator", identity.originator) + req.Header.Set("User-Agent", identity.userAgent) + req.Header.Set("Version", identity.version) + setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) + credentialAccount.ApplyHeaderOverrides(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + return req, nil +} + func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) if strings.TrimSpace(baseURL) == "" { @@ -936,7 +1002,12 @@ func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) } - modelsResp, _, err := client.FetchAvailableModels(ctx, accessToken, strings.TrimSpace(account.GetCredential("project_id"))) + modelsResp, _, err := client.FetchAvailableModels( + ctx, + accessToken, + strings.TrimSpace(account.GetCredential("project_id")), + resolveModelsListReadLimit(s.cfg), + ) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 4ee3046062..dfcaac7158 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -163,6 +163,11 @@ func TestExtractUpstreamModelIDs(t *testing.T) { body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`, want: []string{"canonical-id"}, }, + { + name: "codex manifest uses slug", + body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`, + want: []string{"gpt-5.5-codex", "gpt-5.6-sol"}, + }, } for _, tt := range tests { @@ -177,6 +182,53 @@ func TestExtractUpstreamModelIDs(t *testing.T) { } } +func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) { + svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) + require.NotEmpty(t, req.URL.Query().Get("client_version")) + require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization")) + require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id")) + require.NotEmpty(t, req.Header.Get("Originator")) + require.NotEmpty(t, req.Header.Get("User-Agent")) + require.NotEmpty(t, req.Header.Get("Version")) +} + +func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)), + }} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + } + + models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 12, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models) + require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization")) +} + func TestExtractGrokUpstreamModelIDs(t *testing.T) { t.Parallel() @@ -695,6 +747,31 @@ func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails( require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") } +func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)), + }} + cfg := upstreamModelSyncTestConfig() + cfg.Gateway.ModelsListReadMaxBytes = 8 + svc := &AccountTestService{httpUpstream: upstream, cfg: cfg} + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "openai-key", + "base_url": "https://openai.example.com/v1", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "response exceeds 8 bytes") +} + func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/upstream_response_model.go b/backend/internal/service/upstream_response_model.go index b7c304e488..64cb048cc0 100644 --- a/backend/internal/service/upstream_response_model.go +++ b/backend/internal/service/upstream_response_model.go @@ -20,10 +20,23 @@ const ( // channel explicitly configured with billing_model_source = response_model, // where a conflict flag makes billing fall back to the baseline model // (see responseModelBillingDeclaration). +// +// The same observer also records the service tier the upstream reports having +// used (OpenAI service_tier, Anthropic usage.speed). The billable tier is +// resolved by resolvedOpenAIUpstreamServiceTierFromObserver (upstream echo +// first, outbound body tier as fallback); the upstream ResolveBillingServiceTier +// only-lowers path additionally audits downgrades at usage-record time. type upstreamResponseModelObserver struct { first string terminal string conflict bool + + // firstTier holds the first non-terminal tier declaration; it is discarded + // when later non-terminal declarations disagree. terminalTier comes from a + // terminal event and always wins. + firstTier string + firstTierConflict bool + terminalTier string } func (o *upstreamResponseModelObserver) Observe(model string, terminal bool) { @@ -57,17 +70,98 @@ func normalizeObservedUpstreamResponseModel(model string) string { } func (o *upstreamResponseModelObserver) ObserveOpenAI(payload []byte, eventType string) { - model := firstValidTrimmedGJSONModel(payload, "response.model", "model") - o.Observe(model, isUpstreamResponseModelTerminalEvent(eventType)) + model := firstValidTrimmedGJSONString(payload, "response.model", "model") + terminal := isUpstreamResponseModelTerminalEvent(eventType) + o.Observe(model, terminal) + // Every payload that declares a service tier also declares a model, so + // model-free delta frames skip the extra lookups entirely. + if model == "" { + return + } + // Non-terminal Responses API events echo the requested tier rather than the + // tier actually used. Only terminal events and untyped payloads (chat + // completions chunks, non-streaming bodies) report the processing tier. + if !terminal && strings.TrimSpace(eventType) != "" { + return + } + tier := normalizeObservedOpenAIServiceTier(firstValidTrimmedGJSONString(payload, "response.service_tier", "service_tier")) + o.ObserveServiceTier(tier, terminal) } func (o *upstreamResponseModelObserver) ObserveAnthropic(payload []byte) { - model := firstValidTrimmedGJSONModel(payload, "message.model", "model") + model := firstValidTrimmedGJSONString(payload, "message.model", "model") o.Observe(model, false) + // usage.speed travels with the message object (message_start in streams, + // the top-level body otherwise), i.e. only in payloads that declare a model. + if model == "" { + return + } + tier := normalizeObservedAnthropicSpeed(firstValidTrimmedGJSONString(payload, "message.usage.speed", "usage.speed")) + o.ObserveServiceTier(tier, false) +} + +// ObserveServiceTier records a tier declared by the upstream response. A +// terminal declaration always wins; non-terminal declarations are only trusted +// when they agree with each other. +func (o *upstreamResponseModelObserver) ObserveServiceTier(tier string, terminal bool) { + if o == nil || tier == "" { + return + } + if terminal { + o.terminalTier = tier + return + } + if o.firstTier == "" { + o.firstTier = tier + return + } + if o.firstTier != tier { + o.firstTierConflict = true + } +} + +// ServiceTier returns the tier the upstream reports having used, or "" when the +// response never declared one unambiguously. +func (o *upstreamResponseModelObserver) ServiceTier() string { + if o == nil { + return "" + } + if o.terminalTier != "" { + return o.terminalTier + } + if o.firstTierConflict { + return "" + } + return o.firstTier +} + +// normalizeObservedOpenAIServiceTier maps a tier reported by an OpenAI response +// onto the billing vocabulary. "auto" never describes a processing tier and +// unknown values are ignored rather than guessed at. +func normalizeObservedOpenAIServiceTier(raw string) string { + switch value := strings.ToLower(strings.TrimSpace(raw)); value { + case "priority", "fast": + return OpenAIFastTierPriority + case "default", "flex", "scale": + return value + default: + return "" + } +} + +// normalizeObservedAnthropicSpeed maps Anthropic usage.speed onto the billing +// vocabulary: "fast" is the billable fast-mode tier, "standard" the base rate. +func normalizeObservedAnthropicSpeed(raw string) string { + switch value := strings.ToLower(strings.TrimSpace(raw)); value { + case "fast", "standard": + return value + default: + return "" + } } func (o *upstreamResponseModelObserver) ObserveGemini(payload []byte) { - model := firstValidTrimmedGJSONModel( + model := firstValidTrimmedGJSONString( payload, "modelVersion", "response.modelVersion", @@ -120,6 +214,37 @@ func observedUpstreamResponseModelConflict(c *gin.Context) bool { return upstreamResponseModelObserverFromContext(c).Conflict() } +func observedUpstreamResponseServiceTier(c *gin.Context) string { + return upstreamResponseModelObserverFromContext(c).ServiceTier() +} + +// resolvedOpenAIUpstreamServiceTierFromObserver 返回计费/用量日志实际使用的 +// service tier: +// +// 1. observer 记录到的上游真实回显优先——只有上游实际给了 priority/fast 才按 +// Fast 计费;上游回显 default/flex/auto 等则如实采用并据此计费; +// 2. 上游未回显时,回退到「最终出站 body」里的 tier(经过 fast policy +// filter/force 之后),保证 policy filter 删掉字段后不再按原请求 Fast 计费。 +// +// HTTP→WS 等使用局部 observer 的路径必须把该 observer 传进来,不能只读 +// Gin context——局部 observer 不会自动写入 context。 +func resolvedOpenAIUpstreamServiceTierFromObserver(observer *upstreamResponseModelObserver, outboundBodyTier *string) *string { + if observer != nil { + if tier := strings.TrimSpace(observer.ServiceTier()); tier != "" { + return normalizeOpenAIServiceTier(tier) + } + } + return outboundBodyTier +} + +// resolvedOpenAIUpstreamServiceTier 读取 Gin context 上的 observer 后委托 +// resolvedOpenAIUpstreamServiceTierFromObserver。标准 HTTP 转发路径通过 +// beginUpstreamResponseModelObservation 把 observer 挂到 context;局部 +// observer 路径应直接调用 FromObserver。 +func resolvedOpenAIUpstreamServiceTier(c *gin.Context, outboundBodyTier *string) *string { + return resolvedOpenAIUpstreamServiceTierFromObserver(upstreamResponseModelObserverFromContext(c), outboundBodyTier) +} + func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) { if observer == nil || strings.TrimSpace(body) == "" { return @@ -129,7 +254,7 @@ func observeOpenAISSEBody(observer *upstreamResponseModelObserver, body string) }) } -func firstValidTrimmedGJSONModel(payload []byte, paths ...string) string { +func firstValidTrimmedGJSONString(payload []byte, paths ...string) string { if len(payload) == 0 { return "" } @@ -138,14 +263,14 @@ func firstValidTrimmedGJSONModel(payload []byte, paths ...string) string { if !value.Exists() || value.Type != gjson.String { continue } - if model := strings.TrimSpace(value.String()); model != "" { + if text := strings.TrimSpace(value.String()); text != "" { // Validate only after finding a candidate. This avoids a full validation // pass on the common model-free delta path while still rejecting malformed - // payloads that appear to declare a model. + // payloads that appear to declare a value. if !gjson.ValidBytes(payload) { return "" } - return model + return text } } return "" diff --git a/backend/internal/service/upstream_response_model_bench_test.go b/backend/internal/service/upstream_response_model_bench_test.go index 93d6854f72..ec56fe4b46 100644 --- a/backend/internal/service/upstream_response_model_bench_test.go +++ b/backend/internal/service/upstream_response_model_bench_test.go @@ -35,7 +35,7 @@ func BenchmarkUpstreamResponseModelOpenAI(b *testing.B) { b.Run("optimized", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel(tt.payload, "response.model", "model") + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONString(tt.payload, "response.model", "model") } }) }) @@ -52,7 +52,7 @@ func BenchmarkUpstreamResponseModelAntigravityWrapper(b *testing.B) { b.Run("optimized", func(b *testing.B) { b.ReportAllocs() for b.Loop() { - upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONModel( + upstreamResponseModelBenchmarkSink = firstValidTrimmedGJSONString( upstreamResponseModelBenchmarkWrapper, "modelVersion", "response.modelVersion", diff --git a/backend/internal/service/upstream_response_model_test.go b/backend/internal/service/upstream_response_model_test.go index 51374ec1f3..ea616d53ee 100644 --- a/backend/internal/service/upstream_response_model_test.go +++ b/backend/internal/service/upstream_response_model_test.go @@ -194,3 +194,79 @@ func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) { require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength) } + +func TestUpstreamResponseModelObserverServiceTierTerminalEventWins(t *testing.T) { + observer := &upstreamResponseModelObserver{} + + // response.created echoes the requested tier and must not count as a declaration. + observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"priority"}}`), "response.created") + require.Equal(t, "", observer.ServiceTier()) + + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default","usage":{"input_tokens":1,"output_tokens":2}}}`), "response.completed") + require.Equal(t, "default", observer.ServiceTier()) + require.False(t, observer.Conflict(), "tier echo must not be reported as a model conflict") +} + +func TestUpstreamResponseModelObserverServiceTierUntypedPayloads(t *testing.T) { + t.Run("chat completions chunks agree", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "") + observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "") + require.Equal(t, "default", observer.ServiceTier()) + }) + + t.Run("disagreeing chunks are not trusted", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"priority"}`), "") + observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"default"}`), "") + require.Equal(t, "", observer.ServiceTier()) + }) + + t.Run("non-stream body normalises fast and drops auto", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.6-sol","service_tier":"fast"}`), "") + require.Equal(t, "priority", observer.ServiceTier()) + + auto := &upstreamResponseModelObserver{} + auto.ObserveOpenAI([]byte(`{"id":"resp_2","object":"response","model":"gpt-5.6-sol","service_tier":"auto"}`), "") + require.Equal(t, "", auto.ServiceTier()) + }) + + t.Run("model-free deltas never declare a tier", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveOpenAI([]byte(`{"type":"response.output_text.delta","delta":"hi","service_tier":"default"}`), "response.output_text.delta") + require.Equal(t, "", observer.ServiceTier()) + }) +} + +func TestUpstreamResponseModelObserverServiceTierAnthropicSpeed(t *testing.T) { + t.Run("message_start reports standard", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":1,"speed":"standard"}}}`)) + observer.ObserveAnthropic([]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}`)) + require.Equal(t, "standard", observer.ServiceTier()) + require.Equal(t, "claude-opus-5", observer.Model()) + }) + + t.Run("non-stream body reports fast", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12,"speed":"fast"}}`)) + require.Equal(t, "fast", observer.ServiceTier()) + }) + + t.Run("missing speed declares nothing", func(t *testing.T) { + observer := &upstreamResponseModelObserver{} + observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12}}`)) + require.Equal(t, "", observer.ServiceTier()) + }) +} + +func TestObservedUpstreamResponseServiceTierFromContext(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + require.Equal(t, "", observedUpstreamResponseServiceTier(c)) + + observer := beginUpstreamResponseModelObservation(c) + observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default"}}`), "response.completed") + require.Equal(t, "default", observedUpstreamResponseServiceTier(c)) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 4c34a62e30..6e708de1d9 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -189,6 +189,29 @@ func ProvideOpenAIQuotaService( return service } +// ProvideOpenAIQuotaAutoResetService 启动账号级自动用卡队列与补偿扫描。 +func ProvideOpenAIQuotaAutoResetService( + accountRepo AccountRepository, + quotaService *OpenAIQuotaService, + rateLimitService *RateLimitService, + idempotency *IdempotencyCoordinator, + audit *AuditLogService, + settingService *SettingService, + leaderLock LeaderLockCache, +) *OpenAIQuotaAutoResetService { + service := NewOpenAIQuotaAutoResetService( + accountRepo, + quotaService, + rateLimitService, + idempotency, + audit, + settingService, + leaderLock, + ) + service.Start() + return service +} + func ProvideAccountUsageService( accountRepo AccountRepository, usageLogRepo UsageLogRepository, @@ -231,6 +254,7 @@ func ProvideAccountTestService( tlsFPProfileService *TLSFingerprintProfileService, openAIGatewayService *OpenAIGatewayService, settingService *SettingService, + pluginManager *PluginManager, ) *AccountTestService { service := NewAccountTestService( accountRepo, @@ -244,6 +268,7 @@ func ProvideAccountTestService( ) service.agentIdentityWS = openAIGatewayService service.SetSettingService(settingService) + service.SetPluginManager(pluginManager) return service } @@ -840,6 +865,7 @@ var ProviderSet = wire.NewSet( ProvideGrokTokenProvider, ProvideOpenAITokenProvider, ProvideOpenAIQuotaService, + ProvideOpenAIQuotaAutoResetService, ProvideGrokQuotaService, ProvideCNProviderQuotaService, ProvideCNProviderBalanceService, @@ -895,6 +921,7 @@ var ProviderSet = wire.NewSet( NewTotpService, NewErrorPassthroughService, NewTLSFingerprintProfileService, + NewPluginManager, NewDigestSessionStore, ProvideIdempotencyCoordinator, ProvideSystemOperationLockService, @@ -905,6 +932,7 @@ var ProviderSet = wire.NewSet( NewChannelService, wire.Bind(new(ChannelCacheInvalidator), new(*ChannelService)), NewModelPricingResolver, + NewModelPlazaService, NewContentModerationService, NewAffiliateService, ProvidePaymentConfigService, diff --git a/backend/migrations/229_plugins.sql b/backend/migrations/229_plugins.sql new file mode 100644 index 0000000000..247fb7a936 --- /dev/null +++ b/backend/migrations/229_plugins.sql @@ -0,0 +1,54 @@ +-- 管理员手动上传的本地进程插件。 +-- 插件默认停用;账号表不保存任何插件字段。 +-- 表名使用 sub2api 前缀,避免与部署数据库中已有的通用插件表冲突。 + +CREATE TABLE IF NOT EXISTS sub2api_plugin_installations ( + id BIGSERIAL PRIMARY KEY, + plugin_key VARCHAR(160) NOT NULL UNIQUE, + name VARCHAR(160) NOT NULL, + version VARCHAR(64) NOT NULL, + description TEXT NOT NULL DEFAULT '', + author VARCHAR(160) NOT NULL DEFAULT '', + manifest JSONB NOT NULL DEFAULT '{}'::jsonb, + artifact_path TEXT NOT NULL, + install_path TEXT NOT NULL, + binary_path TEXT NOT NULL, + binary_sha256 VARCHAR(64) NOT NULL, + signature_status VARCHAR(32) NOT NULL DEFAULT 'unsigned', + state VARCHAR(32) NOT NULL DEFAULT 'disabled', + config_encrypted TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + installed_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + enabled_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT sub2api_plugin_installations_state_check + CHECK (state IN ('disabled', 'starting', 'enabled', 'error', 'incompatible')), + CONSTRAINT sub2api_plugin_installations_signature_status_check + CHECK (signature_status IN ('trusted', 'unsigned')) +); + +CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings ( + id BIGSERIAL PRIMARY KEY, + plugin_id BIGINT NOT NULL REFERENCES sub2api_plugin_installations(id) ON DELETE CASCADE, + capability VARCHAR(160) NOT NULL, + platform VARCHAR(32) NOT NULL, + account_type VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + rollout_percent INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT sub2api_plugin_bindings_rollout_check CHECK (rollout_percent BETWEEN 0 AND 100), + CONSTRAINT sub2api_plugin_bindings_scope_unique UNIQUE (plugin_id, capability, platform, account_type) +); + +CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_plugin_id + ON sub2api_plugin_bindings(plugin_id); + +CREATE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_enabled_scope + ON sub2api_plugin_bindings(platform, account_type, capability) + WHERE enabled = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope + ON sub2api_plugin_bindings(capability, platform, account_type) + WHERE enabled = TRUE; diff --git a/backend/migrations/230_plugin_artifacts.sql b/backend/migrations/230_plugin_artifacts.sql new file mode 100644 index 0000000000..c296502852 --- /dev/null +++ b/backend/migrations/230_plugin_artifacts.sql @@ -0,0 +1,4 @@ +-- 保存经过签名校验的原始插件包,供多实例和无状态节点重新复验、解包。 +-- 旧安装记录允许暂时为空;管理员重新上传该插件后会自动补齐。 +ALTER TABLE sub2api_plugin_installations + ADD COLUMN IF NOT EXISTS artifact_data BYTEA; diff --git a/backend/migrations/plugins_migration_test.go b/backend/migrations/plugins_migration_test.go new file mode 100644 index 0000000000..10a1493bc2 --- /dev/null +++ b/backend/migrations/plugins_migration_test.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPluginsMigrationKeepsAccountSchemaUnchanged(t *testing.T) { + content, err := FS.ReadFile("229_plugins.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_installations") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS sub2api_plugin_bindings") + require.Contains(t, sql, "config_encrypted TEXT NOT NULL DEFAULT ''") + require.Contains(t, sql, "REFERENCES sub2api_plugin_installations(id)") + require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS idx_sub2api_plugin_bindings_one_enabled_scope") + require.Contains(t, sql, "WHERE enabled = TRUE") + require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_installations") + require.NotContains(t, sql, "CREATE TABLE IF NOT EXISTS plugin_bindings") + require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS") + require.NotContains(t, sql, "account_id") +} + +func TestPluginArtifactMigrationSupportsExistingInstallations(t *testing.T) { + content, err := FS.ReadFile("230_plugin_artifacts.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "ALTER TABLE sub2api_plugin_installations") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS artifact_data BYTEA") + require.NotContains(t, strings.ToUpper(sql), "ALTER TABLE ACCOUNTS") +} diff --git a/backend/pkg/pluginapi/README.md b/backend/pkg/pluginapi/README.md new file mode 100644 index 0000000000..b8f8b0efff --- /dev/null +++ b/backend/pkg/pluginapi/README.md @@ -0,0 +1,80 @@ +# Sub2API 本地插件协议 + +本目录是插件开发者可以依赖的公开契约。`v1/plugin.proto` 和 `v1/runtime.go` 定义进程协议,`v1/manifest.schema.json` 定义包清单,`docs/` 记录开发和发布规范。Provider 私有实现不应放入本目录。 + +## 开发文档 + +- [开发指南](docs/development.md):从运行时、配置到集成测试的完整流程。 +- [UI Bridge](docs/ui-bridge.md):沙箱配置 UI 的消息结构和安全要求。 +- [包格式](docs/package-format.md):清单、文件哈希、签名和版本规则。 +- [安全边界](docs/security.md):进程权限、敏感数据和故障策略。 + +## 实体与运行方式 + +插件的交付实体是一个 `.s2plugin` 文件,本质上是带清单、签名、独立可执行文件和静态 UI 的 ZIP 包。管理员在独立的插件管理页手动上传,Sub2API 不从网络自动下载插件,也不要求 Docker。 + +启用后,Sub2API 以子进程方式拉起当前操作系统和 CPU 架构对应的二进制,通过本机 gRPC 流传递请求与响应。插件进程退出时会随 Sub2API 清理;停用时先停止接收新请求,再等待正在处理的请求结束。 + +多实例部署不要求共享插件目录。宿主会在数据库保存已验签的原始插件包,各实例缺少本地文件时会重新验签和解包,并周期性对齐启用状态、灰度比例和加密配置。所有实例必须连接同一数据库并使用相同的加密密钥。 + +独立进程是代码和发布边界,不是操作系统安全沙箱。插件拥有 Sub2API 服务用户所拥有的文件和网络权限,因此只应安装可信发布者的签名包。闭源二进制可提高源码分发门槛,但不能承诺无法反编译。 + +## 初期能力边界 + +当前只接受 `openai.oauth.outbound_transport.v1`: + +- 仅匹配 `platform=openai` 且 `account_type=oauth` 的上游 HTTP 请求。 +- API Key 账号、其他 provider、OAuth 登录与 Token 刷新流程不进入插件。 +- 插件建立真实的上游 HTTP/TLS 连接并返回原始 HTTP 响应。 +- 命中插件的 OAuth WebSocket 账号会使用 Sub2API 现有 HTTP Bridge,不直接建立上游 WebSocket,避免绕过 v1 HTTP 插件协议。 +- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。 +- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。 + +## 包结构 + +```text +manifest.json +signature.json # 生产包必需 +runtimes/linux-amd64/plugin +runtimes/linux-arm64/plugin +runtimes/windows-amd64/plugin.exe +ui/index.html +ui/assets/... +``` + +`manifest.json` 必须声明所有运行时和 UI 文件的 SHA-256。`signature.json` 使用受信任发布者的 Ed25519 私钥对 `manifest.json` 原始字节签名。官方 OpenAI Transport 公钥由宿主内置,第三方发布者公钥由部署者追加到 `plugins.trusted_publishers`。文件哈希由已签名清单保护。 + +插件默认保持停用。未签名包默认拒绝安装;`plugins.allow_unsigned` 只应用于开发者自己构建的本地调试包。 + +## 兼容性 + +清单必须同时声明: + +- `requires.sub2api`:允许的 Sub2API 语义化版本范围。 +- `requires.recommended_sub2api_version`:建议使用的宿主版本。 +- `requires.tested_sub2api_versions`:发布者实际验证过的宿主版本。 +- `plugin_protocol`、`transport_api`、`ui_bridge`:三个独立协议版本。 + +宿主版本超出范围时,插件可以安装并查看,但保持“不兼容”状态且不能启用。版本在范围内但未列入已测试版本时,管理员必须再次确认才能启用。 + +## UI 隔离与 Bridge + +插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。 + +UI 可以发送以下消息: + +- `config.load` +- `config.save` +- `config.test` +- `ui.resize` +- `ui.notify` + +每个请求消息带 `request_id`,宿主以 `.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。 + +## 协议源码 + +- `v1/plugin.proto`:稳定的进程间消息定义。 +- `v1/runtime.go`:Go 插件进程启动入口和宿主客户端声明。 +- `v1/manifest.schema.json`:`manifest.json` 的 JSON Schema。 + +插件通过进程协议协作,不使用 Go 动态链接,也不要求插件与 Sub2API 使用相同编译器或共享内存 ABI。 diff --git a/backend/pkg/pluginapi/docs/development.md b/backend/pkg/pluginapi/docs/development.md new file mode 100644 index 0000000000..65d9d0f415 --- /dev/null +++ b/backend/pkg/pluginapi/docs/development.md @@ -0,0 +1,67 @@ +# 插件开发指南 + +## 稳定边界 + +当前宿主只支持 `openai.oauth.outbound_transport.v1`。插件负责建立实际上游 HTTP/TLS 连接,Sub2API 负责账号选择、OAuth Token 生命周期、下游协议、响应解析、SSE、错误映射、用量统计和计费。 + +插件不应修改 API Key 路径,也不应自行刷新或持久化 OAuth Token。 + +## 推荐结构 + +```text +plugin/ +├── cmd//main.go +├── internal/config/ +├── internal/transport/ +├── ui/index.html +├── ui/assets/ +├── tools/packager/ +├── manifest.source.json +└── README.md +``` + +入口只调用 `pluginv1.Serve`。配置解析和传输实现放入独立包,以便不启动子进程就能单元测试。 + +## 运行时方法 + +| 方法 | 要求 | +|---|---| +| `GetInfo` | ID、版本、协议和能力必须与清单一致 | +| `Health` | 返回进程是否可以接受新请求,不执行昂贵探测 | +| `ValidateConfig` | 严格解析并返回完整规范化 JSON | +| `ApplyConfig` | 原子应用配置;失败时保留旧配置 | +| `TestConfig` | 验证当前环境和已保存配置,返回简短诊断 | +| `Forward` | 双向流式传输请求与原始 HTTP 响应 | + +请求帧顺序:`start`、零到多个 `body_chunk`、`body_end`。响应帧顺序:`start`、零到多个 `body_chunk`、`end`。不能继续处理的错误使用 `error` 帧。 + +`request_sent` 必须如实表示请求是否可能已经到达上游。值为 `true` 时宿主禁止自动切换账号重放;只有能确认尚未调用上游 Transport 时才能返回 `false`。 + +## 配置 + +- JSON 字段统一使用 `snake_case`。 +- 拒绝未知字段、非法范围和受保护请求头。 +- 默认配置必须完整,空对象应规范化为所有默认字段。 +- 保存时由插件先验证和应用,再由宿主加密写入数据库。 +- 数据库写入失败时宿主会尝试恢复旧配置,插件必须允许重复应用。 + +## 资源管理 + +- 复用 HTTP Transport 和连接池,不要为每个请求创建新连接池。 +- 配置切换后关闭旧空闲连接。 +- 使用 stream context 取消 DNS、连接、上传和响应读取。 +- 始终关闭上游响应体。 +- 不在插件内无限缓存按账号区分的客户端。 + +## 最低测试集 + +- 配置默认值、未知字段、边界值和深复制。 +- 插件身份及协议版本。 +- 请求体分块、无请求体、固定 Content-Length。 +- 响应状态、重复请求头、流式响应和响应读取错误。 +- 上下文取消、插件退出和超时。 +- 代理开启与禁用。 +- 包哈希、签名、路径穿越和目标平台运行时。 +- UI Bridge 加载、保存、测试、错误和超时。 + +发布前还应使用真实构建包运行宿主的插件进程集成测试。 diff --git a/backend/pkg/pluginapi/docs/package-format.md b/backend/pkg/pluginapi/docs/package-format.md new file mode 100644 index 0000000000..750760808f --- /dev/null +++ b/backend/pkg/pluginapi/docs/package-format.md @@ -0,0 +1,45 @@ +# `.s2plugin` 包格式 + +`.s2plugin` 是 ZIP 文件,根目录必须包含 `manifest.json`,生产包还必须包含 `signature.json`。 + +## 标准布局 + +```text +manifest.json +signature.json +runtimes/-/ +ui/index.html +ui/assets/... +``` + +所有运行时和 UI 文件必须出现在 `manifest.files`,值为小写十六进制 SHA-256。清单和签名文件自身不写入 `files`。 + +包不允许绝对路径、父目录跳转、重复路径、符号链接、未声明文件或缺失文件。宿主还限制上传大小、解压后大小和文件数量。 + +## 清单 + +字段规范见 [`v1/manifest.schema.json`](../v1/manifest.schema.json)。版本字段含义: + +- `version`:插件自身语义化版本。 +- `requires.sub2api`:宿主硬兼容范围。 +- `recommended_sub2api_version`:建议宿主版本。 +- `tested_sub2api_versions`:发布者真实验证过的版本。 +- `plugin_protocol`:进程握手协议。 +- `transport_api`:请求和响应帧协议。 +- `ui_bridge`:配置 UI 消息协议。 + +## 签名 + +`signature.json`: + +```json +{ + "algorithm": "ed25519", + "key_id": "publisher-key-id", + "signature": "BASE64_SIGNATURE" +} +``` + +签名对象是 `manifest.json` 的精确原始字节。发布者私钥不得进入插件包、源码仓库或 Sub2API 运行环境。部署者只配置 Base64 Ed25519 公钥。 + +默认生产配置拒绝未签名包。官方 OpenAI Transport 使用宿主内置公钥验签,不需要配置;其他发布者仍需配置 `trusted_publishers`。`allow_unsigned` 只用于开发者自己构建的本地包。 diff --git a/backend/pkg/pluginapi/docs/security.md b/backend/pkg/pluginapi/docs/security.md new file mode 100644 index 0000000000..1cad27ac69 --- /dev/null +++ b/backend/pkg/pluginapi/docs/security.md @@ -0,0 +1,29 @@ +# 插件安全边界 + +## 能提供的隔离 + +- 私有实现以独立二进制交付,宿主公开源码不包含其业务逻辑。 +- 进程协议避免 Go 动态链接和共享内存 ABI。 +- 包签名和文件哈希防止未授权替换。 +- UI 使用短时 URL、独立 Bridge Token 和 sandbox iframe。 +- 插件故障时 OAuth 插件路径失败关闭,不静默切回另一种网络行为。 + +## 不能提供的保证 + +- 闭源二进制仍可能被逆向分析。 +- 子进程不是操作系统沙箱。 +- 插件拥有 Sub2API 服务用户可访问的文件、环境变量和网络权限。 +- 包签名证明发布者身份,不证明实现无漏洞或符合 Provider 条款。 + +## 部署要求 + +- 官方 OpenAI Transport 使用宿主内置公钥;只向 `plugins.trusted_publishers` 添加经过审核的第三方公钥。 +- 使用专用低权限系统用户运行 Sub2API。 +- 限制该用户的文件权限、出站网络和环境变量。 +- 不向插件环境注入无关密钥。 +- 对插件升级保留旧包和回滚流程。 +- 记录安装、启用、停用、配置和删除操作,但不记录配置明文。 + +## 敏感数据 + +插件处理真实 OAuth Authorization 请求头,必须避免将请求头、请求体、代理凭据和上游敏感响应写入日志或诊断消息。UI 配置中不应出现 OAuth Token。 diff --git a/backend/pkg/pluginapi/docs/ui-bridge.md b/backend/pkg/pluginapi/docs/ui-bridge.md new file mode 100644 index 0000000000..9f39fd0c66 --- /dev/null +++ b/backend/pkg/pluginapi/docs/ui-bridge.md @@ -0,0 +1,56 @@ +# UI Bridge v1 + +## 加载方式 + +宿主为每次打开配置页创建短时 UI 会话: + +```text +/api/v1/plugin-ui//index.html#bridge_token= +``` + +资源 Token 用于读取包内 `ui/` 文件,Bridge Token 只存在于 URL fragment,不会发送到服务器。iframe 使用 `sandbox="allow-scripts"`,不授予 `allow-same-origin`。 + +UI 只能加载包内、已在清单声明的资源。CSP 禁止外部网络连接、表单提交和外部 frame。 + +## 消息信封 + +UI 到宿主: + +```json +{ + "source": "sub2api-plugin-ui", + "bridge_token": "TOKEN", + "type": "config.load", + "request_id": "UNIQUE_ID" +} +``` + +宿主到 UI: + +```json +{ + "source": "sub2api-plugin-host", + "bridge_token": "TOKEN", + "request_id": "UNIQUE_ID", + "ok": true +} +``` + +## 方法 + +| `type` | UI 参数 | 成功响应 | +|---|---|---| +| `sub2api.plugin.ready` | 无 | 无响应 | +| `config.load` | 无 | `config` | +| `config.save` | `config` 对象 | 规范化后的 `config` | +| `config.test` | 无 | `result` | +| `ui.resize` | `height` | 无响应 | +| `ui.notify` | `level`、`message` | 无响应 | + +`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。 + +## 必须执行的校验 + +UI 接收消息时必须验证 `event.source === parent`、消息来源标识、Bridge Token 和等待中的 `request_id`。每个请求必须有超时和卸载清理。 + +宿主不会向 iframe 提供管理员 Token。插件 UI 不得尝试访问管理 API、Cookie、父页面 DOM 或浏览器存储中的宿主数据。 diff --git a/backend/pkg/pluginapi/v1/manifest.schema.json b/backend/pkg/pluginapi/v1/manifest.schema.json new file mode 100644 index 0000000000..e23780ff8f --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sub2api.local/schemas/plugin-manifest-v1.json", + "title": "Sub2API Plugin Manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "name", "version", "requires", "capabilities", "runtimes", "ui", "files"], + "properties": { + "schema_version": { "const": 1 }, + "id": { "type": "string", "maxLength": 160, "pattern": "^[a-z0-9]+([._-][a-z0-9]+)+$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 160 }, + "version": { "type": "string", "pattern": "^v?(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" }, + "description": { "type": "string" }, + "author": { "type": "string", "maxLength": 160 }, + "requires": { + "type": "object", + "additionalProperties": false, + "required": ["sub2api", "plugin_protocol", "transport_api", "ui_bridge"], + "properties": { + "sub2api": { "type": "string", "minLength": 1 }, + "recommended_sub2api_version": { "type": "string" }, + "tested_sub2api_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "plugin_protocol": { "const": 1 }, + "transport_api": { "const": 1 }, + "ui_bridge": { "const": 1 } + } + }, + "capabilities": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "platform", "account_type"], + "properties": { + "id": { "const": "openai.oauth.outbound_transport.v1" }, + "platform": { "const": "openai" }, + "account_type": { "const": "oauth" } + } + } + }, + "runtimes": { + "type": "object", + "minProperties": 1, + "patternProperties": { + "^[a-z0-9]+-[a-z0-9]+$": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { "path": { "$ref": "#/$defs/safePath" } } + } + }, + "additionalProperties": false + }, + "ui": { + "type": "object", + "additionalProperties": false, + "required": ["entrypoint"], + "properties": { "entrypoint": { "type": "string", "pattern": "^ui/.+" } } + }, + "files": { + "type": "object", + "minProperties": 2, + "propertyNames": { "$ref": "#/$defs/safePath" }, + "additionalProperties": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "$defs": { + "safePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$" + } + } +} diff --git a/backend/pkg/pluginapi/v1/manifest_schema_test.go b/backend/pkg/pluginapi/v1/manifest_schema_test.go new file mode 100644 index 0000000000..4805e5300c --- /dev/null +++ b/backend/pkg/pluginapi/v1/manifest_schema_test.go @@ -0,0 +1,21 @@ +package pluginv1 + +import ( + "encoding/json" + "os" + "testing" +) + +func TestManifestSchemaIsValidJSON(t *testing.T) { + raw, err := os.ReadFile("manifest.schema.json") + if err != nil { + t.Fatalf("读取插件清单 Schema 失败: %v", err) + } + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("插件清单 Schema 不是有效 JSON: %v", err) + } + if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" { + t.Fatalf("插件清单 Schema 版本不符合预期: %v", schema["$schema"]) + } +} diff --git a/backend/pkg/pluginapi/v1/plugin.pb.go b/backend/pkg/pluginapi/v1/plugin.pb.go new file mode 100644 index 0000000000..86c6a8f667 --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.pb.go @@ -0,0 +1,1315 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.12.4 +// source: plugin.proto + +package pluginv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoRequest) Reset() { + *x = GetInfoRequest{} + mi := &file_plugin_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoRequest) ProtoMessage() {} + +func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. +func (*GetInfoRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{0} +} + +type GetInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PluginId string `protobuf:"bytes,1,opt,name=plugin_id,json=pluginId,proto3" json:"plugin_id,omitempty"` + PluginVersion string `protobuf:"bytes,2,opt,name=plugin_version,json=pluginVersion,proto3" json:"plugin_version,omitempty"` + ProtocolVersion uint32 `protobuf:"varint,3,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + TransportApiVersion uint32 `protobuf:"varint,4,opt,name=transport_api_version,json=transportApiVersion,proto3" json:"transport_api_version,omitempty"` + Capabilities []string `protobuf:"bytes,5,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoResponse) Reset() { + *x = GetInfoResponse{} + mi := &file_plugin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoResponse) ProtoMessage() {} + +func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. +func (*GetInfoResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{1} +} + +func (x *GetInfoResponse) GetPluginId() string { + if x != nil { + return x.PluginId + } + return "" +} + +func (x *GetInfoResponse) GetPluginVersion() string { + if x != nil { + return x.PluginVersion + } + return "" +} + +func (x *GetInfoResponse) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *GetInfoResponse) GetTransportApiVersion() uint32 { + if x != nil { + return x.TransportApiVersion + } + return 0 +} + +func (x *GetInfoResponse) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_plugin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{2} +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_plugin_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetHealthy() bool { + if x != nil { + return x.Healthy + } + return false +} + +func (x *HealthResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ValidateConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigRequest) Reset() { + *x = ValidateConfigRequest{} + mi := &file_plugin_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigRequest) ProtoMessage() {} + +func (x *ValidateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigRequest.ProtoReflect.Descriptor instead. +func (*ValidateConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{4} +} + +func (x *ValidateConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ValidateConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + NormalizedConfigJson []byte `protobuf:"bytes,3,opt,name=normalized_config_json,json=normalizedConfigJson,proto3" json:"normalized_config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateConfigResponse) Reset() { + *x = ValidateConfigResponse{} + mi := &file_plugin_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateConfigResponse) ProtoMessage() {} + +func (x *ValidateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidateConfigResponse.ProtoReflect.Descriptor instead. +func (*ValidateConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{5} +} + +func (x *ValidateConfigResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *ValidateConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ValidateConfigResponse) GetNormalizedConfigJson() []byte { + if x != nil { + return x.NormalizedConfigJson + } + return nil +} + +type ApplyConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigRequest) Reset() { + *x = ApplyConfigRequest{} + mi := &file_plugin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigRequest) ProtoMessage() {} + +func (x *ApplyConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigRequest.ProtoReflect.Descriptor instead. +func (*ApplyConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{6} +} + +func (x *ApplyConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type ApplyConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Applied bool `protobuf:"varint,1,opt,name=applied,proto3" json:"applied,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyConfigResponse) Reset() { + *x = ApplyConfigResponse{} + mi := &file_plugin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyConfigResponse) ProtoMessage() {} + +func (x *ApplyConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyConfigResponse.ProtoReflect.Descriptor instead. +func (*ApplyConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{7} +} + +func (x *ApplyConfigResponse) GetApplied() bool { + if x != nil { + return x.Applied + } + return false +} + +func (x *ApplyConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type TestConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigJson []byte `protobuf:"bytes,1,opt,name=config_json,json=configJson,proto3" json:"config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigRequest) Reset() { + *x = TestConfigRequest{} + mi := &file_plugin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigRequest) ProtoMessage() {} + +func (x *TestConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigRequest.ProtoReflect.Descriptor instead. +func (*TestConfigRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{8} +} + +func (x *TestConfigRequest) GetConfigJson() []byte { + if x != nil { + return x.ConfigJson + } + return nil +} + +type TestConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + LatencyMs int64 `protobuf:"varint,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestConfigResponse) Reset() { + *x = TestConfigResponse{} + mi := &file_plugin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestConfigResponse) ProtoMessage() {} + +func (x *TestConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestConfigResponse.ProtoReflect.Descriptor instead. +func (*TestConfigResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{9} +} + +func (x *TestConfigResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *TestConfigResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *TestConfigResponse) GetLatencyMs() int64 { + if x != nil { + return x.LatencyMs + } + return 0 +} + +type HeaderValues struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeaderValues) Reset() { + *x = HeaderValues{} + mi := &file_plugin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeaderValues) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeaderValues) ProtoMessage() {} + +func (x *HeaderValues) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead. +func (*HeaderValues) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{10} +} + +func (x *HeaderValues) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type ForwardRequestStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Host string `protobuf:"bytes,4,opt,name=host,proto3" json:"host,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,5,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ProxyUrl string `protobuf:"bytes,6,opt,name=proxy_url,json=proxyUrl,proto3" json:"proxy_url,omitempty"` + AccountId int64 `protobuf:"varint,7,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"` + AccountConcurrency int32 `protobuf:"varint,8,opt,name=account_concurrency,json=accountConcurrency,proto3" json:"account_concurrency,omitempty"` + Platform string `protobuf:"bytes,9,opt,name=platform,proto3" json:"platform,omitempty"` + AccountType string `protobuf:"bytes,10,opt,name=account_type,json=accountType,proto3" json:"account_type,omitempty"` + ContentLength int64 `protobuf:"varint,11,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + HasBody bool `protobuf:"varint,12,opt,name=has_body,json=hasBody,proto3" json:"has_body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequestStart) Reset() { + *x = ForwardRequestStart{} + mi := &file_plugin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequestStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequestStart) ProtoMessage() {} + +func (x *ForwardRequestStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequestStart.ProtoReflect.Descriptor instead. +func (*ForwardRequestStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{11} +} + +func (x *ForwardRequestStart) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ForwardRequestStart) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *ForwardRequestStart) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ForwardRequestStart) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ForwardRequestStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardRequestStart) GetProxyUrl() string { + if x != nil { + return x.ProxyUrl + } + return "" +} + +func (x *ForwardRequestStart) GetAccountId() int64 { + if x != nil { + return x.AccountId + } + return 0 +} + +func (x *ForwardRequestStart) GetAccountConcurrency() int32 { + if x != nil { + return x.AccountConcurrency + } + return 0 +} + +func (x *ForwardRequestStart) GetPlatform() string { + if x != nil { + return x.Platform + } + return "" +} + +func (x *ForwardRequestStart) GetAccountType() string { + if x != nil { + return x.AccountType + } + return "" +} + +func (x *ForwardRequestStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +func (x *ForwardRequestStart) GetHasBody() bool { + if x != nil { + return x.HasBody + } + return false +} + +type ForwardRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardRequest_Start + // *ForwardRequest_BodyChunk + // *ForwardRequest_BodyEnd + Frame isForwardRequest_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardRequest) Reset() { + *x = ForwardRequest{} + mi := &file_plugin_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardRequest) ProtoMessage() {} + +func (x *ForwardRequest) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardRequest.ProtoReflect.Descriptor instead. +func (*ForwardRequest) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{12} +} + +func (x *ForwardRequest) GetFrame() isForwardRequest_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardRequest) GetStart() *ForwardRequestStart { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardRequest) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardRequest) GetBodyEnd() bool { + if x != nil { + if x, ok := x.Frame.(*ForwardRequest_BodyEnd); ok { + return x.BodyEnd + } + } + return false +} + +type isForwardRequest_Frame interface { + isForwardRequest_Frame() +} + +type ForwardRequest_Start struct { + Start *ForwardRequestStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardRequest_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardRequest_BodyEnd struct { + BodyEnd bool `protobuf:"varint,3,opt,name=body_end,json=bodyEnd,proto3,oneof"` +} + +func (*ForwardRequest_Start) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyChunk) isForwardRequest_Frame() {} + +func (*ForwardRequest_BodyEnd) isForwardRequest_Frame() {} + +type ForwardResponseStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + ProtocolMajor int32 `protobuf:"varint,4,opt,name=protocol_major,json=protocolMajor,proto3" json:"protocol_major,omitempty"` + ProtocolMinor int32 `protobuf:"varint,5,opt,name=protocol_minor,json=protocolMinor,proto3" json:"protocol_minor,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ContentLength int64 `protobuf:"varint,7,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseStart) Reset() { + *x = ForwardResponseStart{} + mi := &file_plugin_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseStart) ProtoMessage() {} + +func (x *ForwardResponseStart) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseStart.ProtoReflect.Descriptor instead. +func (*ForwardResponseStart) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{13} +} + +func (x *ForwardResponseStart) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *ForwardResponseStart) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ForwardResponseStart) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *ForwardResponseStart) GetProtocolMajor() int32 { + if x != nil { + return x.ProtocolMajor + } + return 0 +} + +func (x *ForwardResponseStart) GetProtocolMinor() int32 { + if x != nil { + return x.ProtocolMinor + } + return 0 +} + +func (x *ForwardResponseStart) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ForwardResponseStart) GetContentLength() int64 { + if x != nil { + return x.ContentLength + } + return 0 +} + +type ForwardResponseEnd struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesReceived int64 `protobuf:"varint,1,opt,name=bytes_received,json=bytesReceived,proto3" json:"bytes_received,omitempty"` + DurationMs int64 `protobuf:"varint,2,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseEnd) Reset() { + *x = ForwardResponseEnd{} + mi := &file_plugin_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseEnd) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseEnd) ProtoMessage() {} + +func (x *ForwardResponseEnd) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseEnd.ProtoReflect.Descriptor instead. +func (*ForwardResponseEnd) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{14} +} + +func (x *ForwardResponseEnd) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +func (x *ForwardResponseEnd) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +type ForwardResponseError struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + RequestSent bool `protobuf:"varint,3,opt,name=request_sent,json=requestSent,proto3" json:"request_sent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponseError) Reset() { + *x = ForwardResponseError{} + mi := &file_plugin_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponseError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponseError) ProtoMessage() {} + +func (x *ForwardResponseError) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponseError.ProtoReflect.Descriptor instead. +func (*ForwardResponseError) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{15} +} + +func (x *ForwardResponseError) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ForwardResponseError) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ForwardResponseError) GetRequestSent() bool { + if x != nil { + return x.RequestSent + } + return false +} + +type ForwardResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ForwardResponse_Start + // *ForwardResponse_BodyChunk + // *ForwardResponse_End + // *ForwardResponse_Error + Frame isForwardResponse_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForwardResponse) Reset() { + *x = ForwardResponse{} + mi := &file_plugin_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForwardResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardResponse) ProtoMessage() {} + +func (x *ForwardResponse) ProtoReflect() protoreflect.Message { + mi := &file_plugin_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardResponse.ProtoReflect.Descriptor instead. +func (*ForwardResponse) Descriptor() ([]byte, []int) { + return file_plugin_proto_rawDescGZIP(), []int{16} +} + +func (x *ForwardResponse) GetFrame() isForwardResponse_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ForwardResponse) GetStart() *ForwardResponseStart { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ForwardResponse) GetBodyChunk() []byte { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_BodyChunk); ok { + return x.BodyChunk + } + } + return nil +} + +func (x *ForwardResponse) GetEnd() *ForwardResponseEnd { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_End); ok { + return x.End + } + } + return nil +} + +func (x *ForwardResponse) GetError() *ForwardResponseError { + if x != nil { + if x, ok := x.Frame.(*ForwardResponse_Error); ok { + return x.Error + } + } + return nil +} + +type isForwardResponse_Frame interface { + isForwardResponse_Frame() +} + +type ForwardResponse_Start struct { + Start *ForwardResponseStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ForwardResponse_BodyChunk struct { + BodyChunk []byte `protobuf:"bytes,2,opt,name=body_chunk,json=bodyChunk,proto3,oneof"` +} + +type ForwardResponse_End struct { + End *ForwardResponseEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ForwardResponse_Error struct { + Error *ForwardResponseError `protobuf:"bytes,4,opt,name=error,proto3,oneof"` +} + +func (*ForwardResponse_Start) isForwardResponse_Frame() {} + +func (*ForwardResponse_BodyChunk) isForwardResponse_Frame() {} + +func (*ForwardResponse_End) isForwardResponse_Frame() {} + +func (*ForwardResponse_Error) isForwardResponse_Frame() {} + +var File_plugin_proto protoreflect.FileDescriptor + +const file_plugin_proto_rawDesc = "" + + "\n" + + "\fplugin.proto\x12\x11sub2api.plugin.v1\"\x10\n" + + "\x0eGetInfoRequest\"\xd8\x01\n" + + "\x0fGetInfoResponse\x12\x1b\n" + + "\tplugin_id\x18\x01 \x01(\tR\bpluginId\x12%\n" + + "\x0eplugin_version\x18\x02 \x01(\tR\rpluginVersion\x12)\n" + + "\x10protocol_version\x18\x03 \x01(\rR\x0fprotocolVersion\x122\n" + + "\x15transport_api_version\x18\x04 \x01(\rR\x13transportApiVersion\x12\"\n" + + "\fcapabilities\x18\x05 \x03(\tR\fcapabilities\"\x0f\n" + + "\rHealthRequest\"D\n" + + "\x0eHealthResponse\x12\x18\n" + + "\ahealthy\x18\x01 \x01(\bR\ahealthy\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"8\n" + + "\x15ValidateConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"~\n" + + "\x16ValidateConfigResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x124\n" + + "\x16normalized_config_json\x18\x03 \x01(\fR\x14normalizedConfigJson\"5\n" + + "\x12ApplyConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"I\n" + + "\x13ApplyConfigResponse\x12\x18\n" + + "\aapplied\x18\x01 \x01(\bR\aapplied\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"4\n" + + "\x11TestConfigRequest\x12\x1f\n" + + "\vconfig_json\x18\x01 \x01(\fR\n" + + "configJson\"g\n" + + "\x12TestConfigResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + + "\n" + + "latency_ms\x18\x03 \x01(\x03R\tlatencyMs\"&\n" + + "\fHeaderValues\x12\x16\n" + + "\x06values\x18\x01 \x03(\tR\x06values\"\x8c\x04\n" + + "\x13ForwardRequestStart\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" + + "\x06method\x18\x02 \x01(\tR\x06method\x12\x10\n" + + "\x03url\x18\x03 \x01(\tR\x03url\x12\x12\n" + + "\x04host\x18\x04 \x01(\tR\x04host\x12M\n" + + "\aheaders\x18\x05 \x03(\v23.sub2api.plugin.v1.ForwardRequestStart.HeadersEntryR\aheaders\x12\x1b\n" + + "\tproxy_url\x18\x06 \x01(\tR\bproxyUrl\x12\x1d\n" + + "\n" + + "account_id\x18\a \x01(\x03R\taccountId\x12/\n" + + "\x13account_concurrency\x18\b \x01(\x05R\x12accountConcurrency\x12\x1a\n" + + "\bplatform\x18\t \x01(\tR\bplatform\x12!\n" + + "\faccount_type\x18\n" + + " \x01(\tR\vaccountType\x12%\n" + + "\x0econtent_length\x18\v \x01(\x03R\rcontentLength\x12\x19\n" + + "\bhas_body\x18\f \x01(\bR\ahasBody\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\x97\x01\n" + + "\x0eForwardRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.sub2api.plugin.v1.ForwardRequestStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x12\x1b\n" + + "\bbody_end\x18\x03 \x01(\bH\x00R\abodyEndB\a\n" + + "\x05frame\"\x8d\x03\n" + + "\x14ForwardResponseStart\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12%\n" + + "\x0eprotocol_major\x18\x04 \x01(\x05R\rprotocolMajor\x12%\n" + + "\x0eprotocol_minor\x18\x05 \x01(\x05R\rprotocolMinor\x12N\n" + + "\aheaders\x18\x06 \x03(\v24.sub2api.plugin.v1.ForwardResponseStart.HeadersEntryR\aheaders\x12%\n" + + "\x0econtent_length\x18\a \x01(\x03R\rcontentLength\x1a[\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x125\n" + + "\x05value\x18\x02 \x01(\v2\x1f.sub2api.plugin.v1.HeaderValuesR\x05value:\x028\x01\"\\\n" + + "\x12ForwardResponseEnd\x12%\n" + + "\x0ebytes_received\x18\x01 \x01(\x03R\rbytesReceived\x12\x1f\n" + + "\vduration_ms\x18\x02 \x01(\x03R\n" + + "durationMs\"g\n" + + "\x14ForwardResponseError\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12!\n" + + "\frequest_sent\x18\x03 \x01(\bR\vrequestSent\"\xf8\x01\n" + + "\x0fForwardResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.sub2api.plugin.v1.ForwardResponseStartH\x00R\x05start\x12\x1f\n" + + "\n" + + "body_chunk\x18\x02 \x01(\fH\x00R\tbodyChunk\x129\n" + + "\x03end\x18\x03 \x01(\v2%.sub2api.plugin.v1.ForwardResponseEndH\x00R\x03end\x12?\n" + + "\x05error\x18\x04 \x01(\v2'.sub2api.plugin.v1.ForwardResponseErrorH\x00R\x05errorB\a\n" + + "\x05frame2\xa8\x04\n" + + "\x0fTransportPlugin\x12P\n" + + "\aGetInfo\x12!.sub2api.plugin.v1.GetInfoRequest\x1a\".sub2api.plugin.v1.GetInfoResponse\x12M\n" + + "\x06Health\x12 .sub2api.plugin.v1.HealthRequest\x1a!.sub2api.plugin.v1.HealthResponse\x12e\n" + + "\x0eValidateConfig\x12(.sub2api.plugin.v1.ValidateConfigRequest\x1a).sub2api.plugin.v1.ValidateConfigResponse\x12\\\n" + + "\vApplyConfig\x12%.sub2api.plugin.v1.ApplyConfigRequest\x1a&.sub2api.plugin.v1.ApplyConfigResponse\x12Y\n" + + "\n" + + "TestConfig\x12$.sub2api.plugin.v1.TestConfigRequest\x1a%.sub2api.plugin.v1.TestConfigResponse\x12T\n" + + "\aForward\x12!.sub2api.plugin.v1.ForwardRequest\x1a\".sub2api.plugin.v1.ForwardResponse(\x010\x01B7Z5github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1b\x06proto3" + +var ( + file_plugin_proto_rawDescOnce sync.Once + file_plugin_proto_rawDescData []byte +) + +func file_plugin_proto_rawDescGZIP() []byte { + file_plugin_proto_rawDescOnce.Do(func() { + file_plugin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc))) + }) + return file_plugin_proto_rawDescData +} + +var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 19) +var file_plugin_proto_goTypes = []any{ + (*GetInfoRequest)(nil), // 0: sub2api.plugin.v1.GetInfoRequest + (*GetInfoResponse)(nil), // 1: sub2api.plugin.v1.GetInfoResponse + (*HealthRequest)(nil), // 2: sub2api.plugin.v1.HealthRequest + (*HealthResponse)(nil), // 3: sub2api.plugin.v1.HealthResponse + (*ValidateConfigRequest)(nil), // 4: sub2api.plugin.v1.ValidateConfigRequest + (*ValidateConfigResponse)(nil), // 5: sub2api.plugin.v1.ValidateConfigResponse + (*ApplyConfigRequest)(nil), // 6: sub2api.plugin.v1.ApplyConfigRequest + (*ApplyConfigResponse)(nil), // 7: sub2api.plugin.v1.ApplyConfigResponse + (*TestConfigRequest)(nil), // 8: sub2api.plugin.v1.TestConfigRequest + (*TestConfigResponse)(nil), // 9: sub2api.plugin.v1.TestConfigResponse + (*HeaderValues)(nil), // 10: sub2api.plugin.v1.HeaderValues + (*ForwardRequestStart)(nil), // 11: sub2api.plugin.v1.ForwardRequestStart + (*ForwardRequest)(nil), // 12: sub2api.plugin.v1.ForwardRequest + (*ForwardResponseStart)(nil), // 13: sub2api.plugin.v1.ForwardResponseStart + (*ForwardResponseEnd)(nil), // 14: sub2api.plugin.v1.ForwardResponseEnd + (*ForwardResponseError)(nil), // 15: sub2api.plugin.v1.ForwardResponseError + (*ForwardResponse)(nil), // 16: sub2api.plugin.v1.ForwardResponse + nil, // 17: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + nil, // 18: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry +} +var file_plugin_proto_depIdxs = []int32{ + 17, // 0: sub2api.plugin.v1.ForwardRequestStart.headers:type_name -> sub2api.plugin.v1.ForwardRequestStart.HeadersEntry + 11, // 1: sub2api.plugin.v1.ForwardRequest.start:type_name -> sub2api.plugin.v1.ForwardRequestStart + 18, // 2: sub2api.plugin.v1.ForwardResponseStart.headers:type_name -> sub2api.plugin.v1.ForwardResponseStart.HeadersEntry + 13, // 3: sub2api.plugin.v1.ForwardResponse.start:type_name -> sub2api.plugin.v1.ForwardResponseStart + 14, // 4: sub2api.plugin.v1.ForwardResponse.end:type_name -> sub2api.plugin.v1.ForwardResponseEnd + 15, // 5: sub2api.plugin.v1.ForwardResponse.error:type_name -> sub2api.plugin.v1.ForwardResponseError + 10, // 6: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 10, // 7: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues + 0, // 8: sub2api.plugin.v1.TransportPlugin.GetInfo:input_type -> sub2api.plugin.v1.GetInfoRequest + 2, // 9: sub2api.plugin.v1.TransportPlugin.Health:input_type -> sub2api.plugin.v1.HealthRequest + 4, // 10: sub2api.plugin.v1.TransportPlugin.ValidateConfig:input_type -> sub2api.plugin.v1.ValidateConfigRequest + 6, // 11: sub2api.plugin.v1.TransportPlugin.ApplyConfig:input_type -> sub2api.plugin.v1.ApplyConfigRequest + 8, // 12: sub2api.plugin.v1.TransportPlugin.TestConfig:input_type -> sub2api.plugin.v1.TestConfigRequest + 12, // 13: sub2api.plugin.v1.TransportPlugin.Forward:input_type -> sub2api.plugin.v1.ForwardRequest + 1, // 14: sub2api.plugin.v1.TransportPlugin.GetInfo:output_type -> sub2api.plugin.v1.GetInfoResponse + 3, // 15: sub2api.plugin.v1.TransportPlugin.Health:output_type -> sub2api.plugin.v1.HealthResponse + 5, // 16: sub2api.plugin.v1.TransportPlugin.ValidateConfig:output_type -> sub2api.plugin.v1.ValidateConfigResponse + 7, // 17: sub2api.plugin.v1.TransportPlugin.ApplyConfig:output_type -> sub2api.plugin.v1.ApplyConfigResponse + 9, // 18: sub2api.plugin.v1.TransportPlugin.TestConfig:output_type -> sub2api.plugin.v1.TestConfigResponse + 16, // 19: sub2api.plugin.v1.TransportPlugin.Forward:output_type -> sub2api.plugin.v1.ForwardResponse + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_plugin_proto_init() } +func file_plugin_proto_init() { + if File_plugin_proto != nil { + return + } + file_plugin_proto_msgTypes[12].OneofWrappers = []any{ + (*ForwardRequest_Start)(nil), + (*ForwardRequest_BodyChunk)(nil), + (*ForwardRequest_BodyEnd)(nil), + } + file_plugin_proto_msgTypes[16].OneofWrappers = []any{ + (*ForwardResponse_Start)(nil), + (*ForwardResponse_BodyChunk)(nil), + (*ForwardResponse_End)(nil), + (*ForwardResponse_Error)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc)), + NumEnums: 0, + NumMessages: 19, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_plugin_proto_goTypes, + DependencyIndexes: file_plugin_proto_depIdxs, + MessageInfos: file_plugin_proto_msgTypes, + }.Build() + File_plugin_proto = out.File + file_plugin_proto_goTypes = nil + file_plugin_proto_depIdxs = nil +} diff --git a/backend/pkg/pluginapi/v1/plugin.proto b/backend/pkg/pluginapi/v1/plugin.proto new file mode 100644 index 0000000000..e4b8fdac3a --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin.proto @@ -0,0 +1,117 @@ +syntax = "proto3"; + +package sub2api.plugin.v1; + +option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1"; + +service TransportPlugin { + rpc GetInfo(GetInfoRequest) returns (GetInfoResponse); + rpc Health(HealthRequest) returns (HealthResponse); + rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); + rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse); + rpc TestConfig(TestConfigRequest) returns (TestConfigResponse); + rpc Forward(stream ForwardRequest) returns (stream ForwardResponse); +} + +message GetInfoRequest {} + +message GetInfoResponse { + string plugin_id = 1; + string plugin_version = 2; + uint32 protocol_version = 3; + uint32 transport_api_version = 4; + repeated string capabilities = 5; +} + +message HealthRequest {} + +message HealthResponse { + bool healthy = 1; + string message = 2; +} + +message ValidateConfigRequest { + bytes config_json = 1; +} + +message ValidateConfigResponse { + bool valid = 1; + string message = 2; + bytes normalized_config_json = 3; +} + +message ApplyConfigRequest { + bytes config_json = 1; +} + +message ApplyConfigResponse { + bool applied = 1; + string message = 2; +} + +message TestConfigRequest { + bytes config_json = 1; +} + +message TestConfigResponse { + bool success = 1; + string message = 2; + int64 latency_ms = 3; +} + +message HeaderValues { + repeated string values = 1; +} + +message ForwardRequestStart { + string request_id = 1; + string method = 2; + string url = 3; + string host = 4; + map headers = 5; + string proxy_url = 6; + int64 account_id = 7; + int32 account_concurrency = 8; + string platform = 9; + string account_type = 10; + int64 content_length = 11; + bool has_body = 12; +} + +message ForwardRequest { + oneof frame { + ForwardRequestStart start = 1; + bytes body_chunk = 2; + bool body_end = 3; + } +} + +message ForwardResponseStart { + int32 status_code = 1; + string status = 2; + string protocol = 3; + int32 protocol_major = 4; + int32 protocol_minor = 5; + map headers = 6; + int64 content_length = 7; +} + +message ForwardResponseEnd { + int64 bytes_received = 1; + int64 duration_ms = 2; +} + +message ForwardResponseError { + string code = 1; + string message = 2; + bool request_sent = 3; +} + +message ForwardResponse { + oneof frame { + ForwardResponseStart start = 1; + bytes body_chunk = 2; + ForwardResponseEnd end = 3; + ForwardResponseError error = 4; + } +} diff --git a/backend/pkg/pluginapi/v1/plugin_grpc.pb.go b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go new file mode 100644 index 0000000000..ee07e1b1dd --- /dev/null +++ b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go @@ -0,0 +1,306 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.12.4 +// source: plugin.proto + +package pluginv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo" + TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health" + TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig" + TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig" + TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig" + TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward" +) + +// TransportPluginClient is the client API for TransportPlugin service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TransportPluginClient interface { + GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) + ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) + TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) + Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) +} + +type transportPluginClient struct { + cc grpc.ClientConnInterface +} + +func NewTransportPluginClient(cc grpc.ClientConnInterface) TransportPluginClient { + return &transportPluginClient{cc} +} + +func (c *transportPluginClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInfoResponse) + err := c.cc.Invoke(ctx, TransportPlugin_GetInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, TransportPlugin_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) ValidateConfig(ctx context.Context, in *ValidateConfigRequest, opts ...grpc.CallOption) (*ValidateConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidateConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_ValidateConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApplyConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_ApplyConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(TestConfigResponse) + err := c.cc.Invoke(ctx, TransportPlugin_TestConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &TransportPlugin_ServiceDesc.Streams[0], TransportPlugin_Forward_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ForwardRequest, ForwardResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse] + +// TransportPluginServer is the server API for TransportPlugin service. +// All implementations must embed UnimplementedTransportPluginServer +// for forward compatibility. +type TransportPluginServer interface { + GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) + Health(context.Context, *HealthRequest) (*HealthResponse, error) + ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) + ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) + TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) + Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error + mustEmbedUnimplementedTransportPluginServer() +} + +// UnimplementedTransportPluginServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTransportPluginServer struct{} + +func (UnimplementedTransportPluginServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented") +} +func (UnimplementedTransportPluginServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedTransportPluginServer) ValidateConfig(context.Context, *ValidateConfigRequest) (*ValidateConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ValidateConfig not implemented") +} +func (UnimplementedTransportPluginServer) ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApplyConfig not implemented") +} +func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method TestConfig not implemented") +} +func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error { + return status.Error(codes.Unimplemented, "method Forward not implemented") +} +func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {} +func (UnimplementedTransportPluginServer) testEmbeddedByValue() {} + +// UnsafeTransportPluginServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TransportPluginServer will +// result in compilation errors. +type UnsafeTransportPluginServer interface { + mustEmbedUnimplementedTransportPluginServer() +} + +func RegisterTransportPluginServer(s grpc.ServiceRegistrar, srv TransportPluginServer) { + // If the following call panics, it indicates UnimplementedTransportPluginServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TransportPlugin_ServiceDesc, srv) +} + +func _TransportPlugin_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInfoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).GetInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_GetInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).GetInfo(ctx, req.(*GetInfoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_ValidateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).ValidateConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_ValidateConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).ValidateConfig(ctx, req.(*ValidateConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_ApplyConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApplyConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).ApplyConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_ApplyConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).ApplyConfig(ctx, req.(*ApplyConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_TestConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TestConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TransportPluginServer).TestConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TransportPlugin_TestConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TransportPluginServer).TestConfig(ctx, req.(*TestConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TransportPluginServer).Forward(&grpc.GenericServerStream[ForwardRequest, ForwardResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse] + +// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TransportPlugin_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sub2api.plugin.v1.TransportPlugin", + HandlerType: (*TransportPluginServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetInfo", + Handler: _TransportPlugin_GetInfo_Handler, + }, + { + MethodName: "Health", + Handler: _TransportPlugin_Health_Handler, + }, + { + MethodName: "ValidateConfig", + Handler: _TransportPlugin_ValidateConfig_Handler, + }, + { + MethodName: "ApplyConfig", + Handler: _TransportPlugin_ApplyConfig_Handler, + }, + { + MethodName: "TestConfig", + Handler: _TransportPlugin_TestConfig_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Forward", + Handler: _TransportPlugin_Forward_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "plugin.proto", +} diff --git a/backend/pkg/pluginapi/v1/runtime.go b/backend/pkg/pluginapi/v1/runtime.go new file mode 100644 index 0000000000..662519c371 --- /dev/null +++ b/backend/pkg/pluginapi/v1/runtime.go @@ -0,0 +1,59 @@ +package pluginv1 + +import ( + "context" + + hcplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +const ( + // ProtocolVersion 是宿主与插件进程握手协议版本。 + ProtocolVersion = 1 + // TransportAPIVersion 是 OpenAI OAuth 出站传输契约版本。 + TransportAPIVersion = 1 + // UIBridgeVersion 是插件管理页与沙箱 UI 的消息协议版本。 + UIBridgeVersion = 1 + // TransportPluginName 是 go-plugin 中注册的唯一能力名称。 + TransportPluginName = "oauth_transport" +) + +// HandshakeConfig 防止普通可执行文件被误当成 Sub2API 插件启动。 +var HandshakeConfig = hcplugin.HandshakeConfig{ + ProtocolVersion: ProtocolVersion, + MagicCookieKey: "SUB2API_PLUGIN_MAGIC_COOKIE", + MagicCookieValue: "sub2api-plugin-v1", +} + +// GRPCPlugin 把生成的 gRPC 服务注册到 go-plugin 子进程。 +type GRPCPlugin struct { + hcplugin.NetRPCUnsupportedPlugin + Impl TransportPluginServer +} + +func (p *GRPCPlugin) GRPCServer(_ *hcplugin.GRPCBroker, server *grpc.Server) error { + RegisterTransportPluginServer(server, p.Impl) + return nil +} + +func (p *GRPCPlugin) GRPCClient(_ context.Context, _ *hcplugin.GRPCBroker, conn *grpc.ClientConn) (any, error) { + return NewTransportPluginClient(conn), nil +} + +// ClientPluginMap 返回宿主侧使用的插件声明。 +func ClientPluginMap() map[string]hcplugin.Plugin { + return map[string]hcplugin.Plugin{ + TransportPluginName: &GRPCPlugin{}, + } +} + +// Serve 启动一个实现了传输协议的插件进程。 +func Serve(impl TransportPluginServer) { + hcplugin.Serve(&hcplugin.ServeConfig{ + HandshakeConfig: HandshakeConfig, + Plugins: map[string]hcplugin.Plugin{ + TransportPluginName: &GRPCPlugin{Impl: impl}, + }, + GRPCServer: hcplugin.DefaultGRPCServer, + }) +} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 7dc445e3fc..490e768f27 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -5162,12 +5162,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5177,7 +5177,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5210,12 +5210,12 @@ "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, + "cache_read_input_token_cost_priority": 1.25e-6, "input_cost_per_token": 5e-06, "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "input_cost_per_token_priority": 12.5e-6, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, @@ -5225,7 +5225,7 @@ "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token_priority": 75e-6, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", diff --git a/deploy/.env.example b/deploy/.env.example index 6beca88308..76762e303b 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -285,6 +285,11 @@ GATEWAY_FORCE_CODEX_CLI=false GATEWAY_OPENAI_COMPACT_MODEL=gpt-5.4 # OpenAI/Codex 等待上游响应头超时(秒);0 表示不使用本地响应头超时截断。 GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=0 +# 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket。 +# 当代理或网络导致上游 WebSocket 反复重连时可设为 true;这不会禁用 HTTP/2。 +# 如需回退 HTTP/1.1,请另行设置 GATEWAY_OPENAI_HTTP2_ENABLED=false。 +# 将此项保存在持久化 .env 中,镜像更新或容器重建后会在启动时重新读取。 +GATEWAY_OPENAI_WS_FORCE_HTTP=false # OpenAI HTTP 上游默认启用 HTTP/2;如需紧急回滚可设为 false。 GATEWAY_OPENAI_HTTP2_ENABLED=true GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=true diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 30c7a1a240..ab312c4852 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.6-alpine +ARG GOLANG_IMAGE=golang:1.27.0-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 38a50d0607..033cb29908 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'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" proxy_probe: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) @@ -235,6 +235,9 @@ gateway: # Max bytes to read for non-stream upstream responses (default: 8MB) # 非流式上游响应体读取上限(默认 8MB) upstream_response_read_max_bytes: 8388608 + # Max bytes to read for upstream model-list responses (default: 8MB) + # 上游模型列表响应体读取上限(默认 8MB) + models_list_read_max_bytes: 8388608 # Max bytes to read for proxy probe responses (default: 1MB) # 代理探测响应体读取上限(默认 1MB) proxy_probe_response_read_max_bytes: 1048576 @@ -329,7 +332,8 @@ gateway: # 按账号类型细分开关 oauth_enabled: true apikey_enabled: true - # 全局强制 HTTP(紧急回滚开关) + # 全局强制 OpenAI Responses 上游使用 HTTP/SSE,不使用 WebSocket(紧急回滚开关)。 + # 此开关不强制 HTTP/1.1;HTTP/2 由 gateway.openai_http2 单独控制。 force_http: false # 允许在 WSv2 下按策略恢复 store=true(默认 false) allow_store_recovery: false @@ -1025,6 +1029,26 @@ rate_limit: # 上游返回 529(过载)时的冷却时间(分钟) overload_cooldown_minutes: 10 +# ============================================================================= +# Local OAuth Transport Plugins (Optional) +# 本地 OAuth 出站传输插件(可选) +# ============================================================================= +plugins: + # Empty means {{DATA_DIR}}/plugins, or ./data/plugins when DATA_DIR is unset. + # 留空时使用 {{DATA_DIR}}/plugins;未设置 DATA_DIR 时使用 ./data/plugins。 + data_dir: "" + # Keep false in production. Enable only for local development packages you built yourself. + # 生产环境应保持 false;仅调试自己构建的本地插件时临时开启。 + allow_unsigned: false + # Additional Ed25519 public keys indexed by signature.json key_id. + # OpenAI Transport's public key is built in; this map only adds third-party publishers. + # 以 signature.json 中 key_id 为索引的额外 Ed25519 公钥。 + # OpenAI Transport 公钥已内置,此处只需添加第三方发布者。 + trusted_publishers: {} + max_upload_bytes: 134217728 + max_uncompressed_bytes: 268435456 + start_timeout_seconds: 15 + # ============================================================================= # Pricing Data Source (Optional) # 定价数据源(可选) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 8603f121db..392801e0ae 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -53,8 +53,12 @@ services: - HTTPS_PROXY=${SUB2API_DEV_HTTPS_PROXY:-http://host.docker.internal:7897} - ALL_PROXY=${SUB2API_DEV_ALL_PROXY:-socks5://host.docker.internal:7897} - NO_PROXY=${SUB2API_DEV_NO_PROXY:-127.0.0.1,localhost,::1,postgres,redis,sub2api,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,.local} - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -63,8 +67,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 588862e2f2..0df1fb0d8b 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -159,10 +159,14 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -171,8 +175,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index cd0a008871..bca5e48514 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -106,10 +106,14 @@ services: - ANTIGRAVITY_USER_AGENT_VERSION=${ANTIGRAVITY_USER_AGENT_VERSION:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -118,8 +122,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 68a51e78dd..f15b40590d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -156,10 +156,14 @@ services: - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} # ======================================================================= - # Image Generation Stream & Concurrency + # Gateway Upstream, Scheduling & Image Configuration # ======================================================================= - # OpenAI HTTP upstream protocol/timeout + # Gateway values come from Compose .env or the shell. + # Fallbacks mirror Sub2API backend defaults so omitted values preserve behavior. + - GATEWAY_FORCE_CODEX_CLI=${GATEWAY_FORCE_CODEX_CLI:-false} + - GATEWAY_OPENAI_COMPACT_MODEL=${GATEWAY_OPENAI_COMPACT_MODEL:-gpt-5.4} - GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT=${GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT:-0} + - GATEWAY_OPENAI_WS_FORCE_HTTP=${GATEWAY_OPENAI_WS_FORCE_HTTP:-false} - GATEWAY_OPENAI_HTTP2_ENABLED=${GATEWAY_OPENAI_HTTP2_ENABLED:-true} - GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1=${GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1:-true} - GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD=${GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD:-2} @@ -168,8 +172,28 @@ services: - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD:-2} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS:-60} - GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS=${GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS:-600} + - GATEWAY_MAX_BODY_SIZE=${GATEWAY_MAX_BODY_SIZE:-268435456} + - GATEWAY_MAX_CONNS_PER_HOST=${GATEWAY_MAX_CONNS_PER_HOST:-1024} + - GATEWAY_MAX_IDLE_CONNS=${GATEWAY_MAX_IDLE_CONNS:-2560} + - GATEWAY_MAX_IDLE_CONNS_PER_HOST=${GATEWAY_MAX_IDLE_CONNS_PER_HOST:-120} + - GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING=${GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING:-3} + - GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_STICKY_SESSION_WAIT_TIMEOUT:-120s} + - GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT=${GATEWAY_SCHEDULING_FALLBACK_WAIT_TIMEOUT:-30s} + - GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING=${GATEWAY_SCHEDULING_FALLBACK_MAX_WAITING:-100} + - GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED=${GATEWAY_SCHEDULING_LOAD_BATCH_ENABLED:-true} + - GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL=${GATEWAY_SCHEDULING_SLOT_CLEANUP_INTERVAL:-30s} + - GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED=${GATEWAY_SCHEDULING_DB_FALLBACK_ENABLED:-true} + - GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS=${GATEWAY_SCHEDULING_DB_FALLBACK_TIMEOUT_SECONDS:-0} + - GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS=${GATEWAY_SCHEDULING_DB_FALLBACK_MAX_QPS:-0} + - GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_POLL_INTERVAL_SECONDS:-1} + - GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_WARN_SECONDS:-5} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_SECONDS:-10} + - GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES=${GATEWAY_SCHEDULING_OUTBOX_LAG_REBUILD_FAILURES:-3} + - GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=${GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS:-10000} + - GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=${GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS:-300} - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL:-0} - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} diff --git a/deploy/tests/docker-compose-gateway-env-test.sh b/deploy/tests/docker-compose-gateway-env-test.sh new file mode 100644 index 0000000000..f068410ba2 --- /dev/null +++ b/deploy/tests/docker-compose-gateway-env-test.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +gateway_variables=$(mktemp "${TMPDIR:-/tmp}/sub2api-gateway-env.XXXXXX") +cleanup() { + rm -f "$gateway_variables" +} +trap cleanup EXIT HUP INT TERM + +awk ' + /^GATEWAY_[A-Z0-9_]+=/ { + separator = index($0, "=") + print substr($0, 1, separator - 1) "\t" substr($0, separator + 1) + } +' deploy/.env.example > "$gateway_variables" + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + tab=$(printf '\t') + while IFS="$tab" read -r key value; do + # .env.example intentionally includes high-capacity tuning examples for + # these values. An unconfigured Compose deployment must retain the + # backend defaults instead of silently adopting the examples. + case "$key" in + GATEWAY_MAX_CONNS_PER_HOST) value=1024 ;; + GATEWAY_MAX_IDLE_CONNS) value=2560 ;; + GATEWAY_MAX_IDLE_CONNS_PER_HOST) value=120 ;; + esac + + expected=$(printf ' - %s=${%s:-%s}' "$key" "$key" "$value") + expected_count=$(grep -Fxc "$expected" "$compose_file" || true) + key_count=$(grep -Ec "^[[:space:]]*-[[:space:]]*${key}([[:space:]]*=.*)?[[:space:]]*$" "$compose_file" || true) + if [ "$expected_count" -ne 1 ] || [ "$key_count" -ne 1 ]; then + printf '%s must pass %s with the expected fallback exactly once\n' "$compose_file" "$key" >&2 + exit 1 + fi + done < "$gateway_variables" +done + +printf 'docker compose Gateway environment test passed\n' diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md new file mode 100644 index 0000000000..1b7b248947 --- /dev/null +++ b/docs/PLUGIN_DEVELOPMENT.md @@ -0,0 +1,233 @@ +# Sub2API 插件开发教程 + +本文面向希望为 Sub2API 开发、打包和发布插件的团队。插件是独立进程和静态 UI 组成的 `.s2plugin` 包,宿主通过稳定的 gRPC 协议调用它。本文以当前宿主已经定义的 `openai.oauth.outbound_transport.v1` 能力作为协议示例,说明开发者需要准备什么、哪些职责属于插件、哪些职责仍由 Sub2API 负责。 + +本文不是一个可直接安装的完整插件,也不代表 Sub2API 已经发布对应的官方插件包。当前文档主要描述公开协议、宿主边界和开发流程。后续是否发布可安装包、支持哪些 Provider,以及如何提供示例仓库,都需要另行公告。 + +## 1. 准备开发环境 + +建议使用以下环境: + +- Go 1.21 或更高版本; +- Node.js(仅在插件 UI 使用 JavaScript 时需要); +- Git; +- 与目标部署环境一致的构建工具链。 + +协议定义和通用说明位于: + +- `backend/pkg/pluginapi/v1/plugin.proto`:进程间消息和流式请求定义; +- `backend/pkg/pluginapi/v1/runtime.go`:插件进程启动入口; +- `backend/pkg/pluginapi/v1/manifest.schema.json`:包清单 JSON Schema; +- `backend/pkg/pluginapi/docs/`:开发、UI Bridge、包格式和安全边界说明。 + +目前暂未提供可直接复制的官方示例源码。开发者可以按照本文的目录和协议说明创建自己的插件工程;示例仓库发布后,会在本文补充正式的获取地址、目录说明和版本要求。公开协议始终以 `backend/pkg/pluginapi/` 为准。 + +## 2. 创建插件工程 + +在示例仓库发布前,可以先创建一个独立的 Go 工程,目录建议如下: + +```text +my-plugin/ +├── cmd//main.go +├── internal/pluginconfig/ +├── internal/transport/ +├── ui/index.html +├── ui/assets/ +├── tools/ +├── manifest.source.json +└── build.sh +``` + +开发时至少准备以下部分: + +1. `manifest.source.json`:插件 ID、名称、版本、作者、能力和兼容的 Sub2API 版本; +2. `cmd//main.go`:启动入口和运行时版本注入,并同步打包器中的构建目标和二进制名称; +3. `internal/pluginconfig/`:配置结构、默认值、严格校验和规范化; +4. `internal/transport/`:HTTP 客户端、代理、请求头、请求体、网络连接参数、响应流和资源回收; +5. `ui/index.html` 与 `ui/assets/`:插件自己的配置界面; +6. 单元测试、进程集成测试和目标平台构建配置。 + +入口文件应保持很小,只负责调用 `pluginv1.Serve`。实际逻辑放在可独立测试的包中,避免把配置解析、网络请求和协议组装全部写在 `main.go`。 + +## 3. 编写运行时 + +运行时实现 `TransportPlugin` 服务,必须满足以下约定: + +| 方法 | 要求 | +| --- | --- | +| `GetInfo` | 返回的插件 ID、版本、协议版本、传输 API 版本和能力必须与清单一致。 | +| `Health` | 快速返回进程是否可以接收新请求,不执行长时间网络探测。 | +| `ValidateConfig` | 严格解析 JSON,拒绝未知字段和非法范围,并返回完整的规范化配置。 | +| `ApplyConfig` | 成功后原子切换配置;失败时保留旧配置和旧连接。 | +| `TestConfig` | 针对已保存配置进行快速诊断,返回简短、可展示的结果。 | +| `Forward` | 按协议接收请求流,发出上游请求,再按顺序返回响应流。 | + +请求帧顺序为 `start`、零到多个 `body_chunk`、`body_end`;响应帧顺序为 `start`、零到多个 `body_chunk`、`end`。不能继续处理时发送 `error` 帧。 + +`ForwardResponseError.request_sent` 必须准确:只有在能够确认尚未调用上游 HTTP Transport 时才返回 `false`;一旦已经调用,或无法确认上游是否收到请求,就返回 `true`。宿主会据此决定是否允许切换账号重试,避免重复执行同一个请求。 + +资源管理也属于运行时契约:复用 HTTP Transport 和连接池,配置切换时关闭旧空闲连接,沿用 gRPC stream 的 context 取消 DNS、连接、上传和响应读取,并始终关闭上游响应体。日志和错误消息不能包含 Token、代理凭据、完整请求体或敏感响应头。 + +## 4. 设计插件配置 + +插件配置由插件定义,由 Sub2API 加密保存。推荐流程是: + +1. 在 `internal/pluginconfig.Config` 中定义字段和默认值; +2. 使用 `json.Decoder.DisallowUnknownFields` 等严格方式解析; +3. 将空对象规范化为完整默认配置; +4. 在 `ValidateConfig` 和 `ApplyConfig` 中复用同一套校验; +5. 配置应用成功后再让宿主保存,保存失败时允许恢复旧配置。 + +JSON 字段统一使用 `snake_case`。敏感配置不要放入 URL、UI 通知、诊断结果或日志。插件不应从 UI 读取、刷新或持久化 OAuth Token;宿主只在运行时调用需要的网络转发接口。 + +## 5. 实现插件自己的配置 UI + +UI 是插件包内的静态页面,不需要修改 Sub2API 前端源码。宿主会在受限 iframe 中加载 `ui/index.html`,并通过 UI Bridge 提供配置读写和测试能力。 + +页面初始化流程: + +1. 加载包内 HTML、CSS 和 JavaScript; +2. 创建 Bridge 并注册 `message` 监听; +3. 发送 `sub2api.plugin.ready`; +4. 调用 `config.load` 渲染表单; +5. 编辑后调用 `config.save`; +6. 测试前先保存,再调用 `config.test`; +7. 页面卸载时调用 `dispose()`。 + +当前 Bridge 支持: + +| 消息 | 用途 | +| --- | --- | +| `config.load` | 读取当前配置。 | +| `config.save` | 提交配置,由运行时校验、应用并加密保存。 | +| `config.test` | 运行已保存配置的诊断。 | +| `ui.resize` | 调整配置 iframe 高度。 | +| `ui.notify` | 显示成功、错误或提示消息。 | + +每条消息都必须带 `request_id`,并校验 `event.source`、消息来源标识和 Bridge Token。不要依赖 CDN、远程脚本、Cookie 或本地存储。页面需要兼容窄屏和明暗主题,并正确处理加载、保存、测试、超时和未保存状态。 + +详细信封格式见 `backend/pkg/pluginapi/docs/ui-bridge.md`。如果后续示例仓库提供可复用的 Bridge SDK,本文会在示例仓库章节补充对应路径和使用方式。 + +## 6. 编写包清单 + +只维护 `manifest.source.json`,不要手工编辑构建目录中的 `manifest.json`。至少需要填写: + +```json +{ + "schema_version": 1, + "id": "example.openai.transport", + "name": "Example OpenAI Transport", + "version": "0.1.0", + "requires": { + "sub2api": ">=0.1.179 <0.2.0", + "recommended_sub2api_version": "0.1.179", + "tested_sub2api_versions": ["0.1.179"], + "plugin_protocol": 1, + "transport_api": 1, + "ui_bridge": 1 + }, + "capabilities": [ + { + "id": "openai.oauth.outbound_transport.v1", + "platform": "openai", + "account_type": "oauth" + } + ], + "runtimes": {}, + "ui": { "entrypoint": "ui/index.html" }, + "files": {} +} +``` + +打包器会自动填充目标平台运行时、UI 和运行时文件的 SHA-256。清单中的 `requires.sub2api` 是硬兼容范围;`tested_sub2api_versions` 应只填写真实验证过的版本;`recommended_sub2api_version` 用于管理页面展示。当前宿主仅处理 `openai.oauth.outbound_transport.v1`,声明其他能力不会自动产生新路由。后续增加 Provider 支持时,会在协议、能力清单和宿主路由完成适配后,再补充对应的清单示例。 + +## 7. 生成密钥并签名 + +生产包应始终签名,宿主默认拒绝未签名包。可以使用插件工程中的密钥生成工具生成一对 Ed25519 密钥;示例仓库发布后会提供标准工具和完整命令: + +```bash +go run ./tools/keygen -out build/keys/my-publisher +``` + +生成的 `my-publisher.private` 只保存在受控的开发机或 CI Secret 中,不能提交到源码仓库、插件包或部署服务器。公钥是 Base64 文本,可以提供给部署者。 + +插件工程的 `build.sh` 应调用标准打包器。自定义发布者密钥时必须同时提供 `-signing-key` 和 `-key-id`: + +```bash +./build.sh \ + -signing-key /安全目录/my-publisher.private \ + -key-id my-publisher-v1 \ + -output dist/my-openai-plugin.s2plugin +``` + +签名覆盖最终 `manifest.json` 的精确字节;清单中的文件哈希再覆盖运行时和 UI 文件。签名完成后不要重新格式化 `manifest.json`。 + +部署者在 Sub2API 配置文件中追加公钥: + +```yaml +plugins: + allow_unsigned: false + trusted_publishers: + my-publisher-v1: "BASE64_ED25519_PUBLIC_KEY" +``` + +`trusted_publishers` 是在宿主内置官方公钥之外追加的信任来源,不能覆盖内置公钥。`signature.json` 中的 `key_id` 必须与配置键完全一致。密钥轮换时先发布包含新公钥的宿主配置或版本,再发布新签名包,最后再停用旧密钥。 + +开发阶段如需使用未签名包,只应在隔离的本地环境临时设置 `plugins.allow_unsigned: true`,测试完成后立即恢复为 `false`。 + +## 8. 构建、测试和安装 + +在插件目录执行: + +```bash +go test ./... -count=1 +node --check ui/assets/bridge-v1.js +node --check ui/assets/app.js +./build.sh +unzip -t dist/*.s2plugin +``` + +回到 Sub2API 仓库根目录后,再使用真实构建包运行宿主集成测试: + +```bash +cd ../.. +SUB2API_TEST_PLUGIN_PACKAGE=plugins/my-openai-plugin/dist/my-openai-plugin.s2plugin \ + go test ./backend/internal/service -run '^TestPluginRuntimeIntegration$' -count=1 +``` + +最低测试集应覆盖配置默认值和边界值、插件身份、请求和响应分块、流式响应、上下文取消、插件退出、代理开关、包哈希、签名、路径安全、目标平台运行时以及 UI Bridge 的加载、保存、测试、错误和超时。 + +安装后先保持停用,确认清单兼容性、签名和诊断结果,再按账号灰度启用。API Key 账号和未命中灰度的 OAuth 账号继续走 Sub2API 原有路径。 + +## 9. 发布前检查清单 + +- 插件版本与 `GetInfo` 返回值一致; +- `requires.sub2api` 覆盖范围经过验证,没有未经测试的破坏性版本; +- `tested_sub2api_versions` 与实际测试记录一致; +- 每个支持的平台和架构都有运行时文件; +- 生产包存在有效 `signature.json`,公钥已交付部署者; +- 包中没有私钥、源映射、测试数据、日志和临时文件; +- UI 不依赖外部资源,也不保存宿主会话信息; +- 配置切换、请求取消、响应关闭和错误重试语义经过测试; +- 发布说明包含升级、停用、回滚和兼容版本信息。 + +## 10. 常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 安装提示签名不受信任 | 检查 `signature.json.key_id`、Base64 公钥和配置键是否完全一致。 | +| 插件显示不兼容 | 检查 `requires.sub2api`、`plugin_protocol`、`transport_api` 和 `ui_bridge`。 | +| 插件进程无法启动 | 检查目标系统和架构对应的运行时路径、可执行权限和运行用户权限。 | +| 配置页无法加载 | 检查 `ui.entrypoint`、UI 文件哈希、Bridge Token 校验和 iframe 消息来源。 | +| 保存后配置未生效 | 查看 `ValidateConfig`、`ApplyConfig` 返回的规范化配置和诊断信息。 | +| 请求失败后重复执行 | 检查 `ForwardResponseError.request_sent` 是否准确反映请求是否可能已发出。 | + +## 11. 需要扩展能力时 + +如果新插件需要支持其他 Provider、其他账号类型或新的消息字段,应先扩展并版本化公开协议,再由宿主增加能力匹配和生命周期处理。不要仅通过清单声明一个宿主尚未实现的能力。这样可以让旧插件继续运行,也能让新宿主明确拒绝不兼容的插件。 + +Sub2API 后续会持续补充更多 Provider 的插件适配说明,包括能力标识、请求和响应契约、配置字段、UI Bridge 使用方式、版本兼容要求以及测试清单。本文会随着这些能力的落地继续更新,Provider 专属章节会放在本节之后。 + +## 12. 示例仓库预留 + +后续计划提供独立的插件示例仓库,用于存放可复用的运行时骨架、UI 组件、打包工具和各 Provider 的最小实现。目前示例仓库尚未准备完成,因此暂不提供地址;正式发布后会在这里补充仓库地址、适用的 Sub2API 版本、示例插件版本和构建说明。 diff --git a/docs/screenshots/mobile-account-actions-menu.png b/docs/screenshots/mobile-account-actions-menu.png deleted file mode 100644 index f09684a612..0000000000 Binary files a/docs/screenshots/mobile-account-actions-menu.png and /dev/null differ diff --git a/frontend/package.json b/frontend/package.json index 1d4aaf3aef..8af7a4668b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "@vueuse/core": "^10.7.0", "axios": "^1.18.0", "chart.js": "^4.4.1", - "dompurify": "^3.3.1", + "dompurify": "^3.4.14", "driver.js": "^1.4.0", "file-saver": "^2.0.5", "marked": "^17.0.1", @@ -63,7 +63,8 @@ "overrides": { "js-cookie": "3.0.7", "form-data@<4.0.6": ">=4.0.6", - "postcss@<8.5.18": ">=8.5.18" + "postcss@<8.5.18": ">=8.5.18", + "dompurify@<3.4.14": ">=3.4.14" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4c86452cca..fef7a9441b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: js-cookie: 3.0.7 form-data@<4.0.6: '>=4.0.6' postcss@<8.5.18: '>=8.5.18' + dompurify@<3.4.14: '>=3.4.14' importers: @@ -35,8 +36,8 @@ importers: specifier: ^4.4.1 version: 4.5.1 dompurify: - specifier: ^3.3.1 - version: 3.3.1 + specifier: ^3.4.14 + version: 3.4.14 driver.js: specifier: ^1.4.0 version: 1.4.0 @@ -163,12 +164,6 @@ packages: react: '>=18' react-dom: '>=18' - '@ant-design/cssinjs@2.0.1': - resolution: {integrity: sha512-Lw1Z4cUQxdMmTNir67gU0HCpTl5TtkKCJPZ6UBvCqzcOTl/QmMFB6qAEoj8qFl0CuZDX9qQYa3m9+rEKfaBSbA==} - peerDependencies: - react: '>=16.0.0' - react-dom: '>=16.0.0' - '@ant-design/cssinjs@2.1.2': resolution: {integrity: sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==} peerDependencies: @@ -1249,12 +1244,6 @@ packages: react: '>=18.0.0' react-dom: '>=18.0.0' - '@rc-component/util@1.7.0': - resolution: {integrity: sha512-tIvIGj4Vl6fsZFvWSkYw9sAfiCKUXMyhVz6kpKyZbwyZyRPqv2vxYZROdaO1VB4gqTNvUZFXh6i3APUiterw5g==} - peerDependencies: - react: '>=18.0.0' - react-dom: '>=18.0.0' - '@rc-component/virtual-list@1.0.2': resolution: {integrity: sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==} engines: {node: '>=8.x'} @@ -1296,67 +1285,56 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1644,6 +1622,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2322,11 +2301,8 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dompurify@3.3.1: - resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} - - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dompurify@3.4.14: + resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} driver.js@1.4.0: resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} @@ -2641,11 +2617,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -4398,6 +4375,7 @@ packages: vue-i18n@9.14.5: resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} engines: {node: '>= 16'} + deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html peerDependencies: vue: ^3.0.0 @@ -4570,18 +4548,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@ant-design/cssinjs@2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/runtime': 7.28.4 - '@emotion/hash': 0.8.0 - '@emotion/unitless': 0.7.5 - '@rc-component/util': 1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - clsx: 2.1.1 - csstype: 3.2.3 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - stylis: 4.3.6 - '@ant-design/cssinjs@2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -4792,7 +4758,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -4835,7 +4801,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -5780,13 +5746,6 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-is: 18.3.1 - '@rc-component/util@1.7.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - is-mobile: 5.0.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-is: 18.3.1 - '@rc-component/virtual-list@1.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.2 @@ -6041,7 +6000,7 @@ snapshots: '@types/dompurify@3.2.0': dependencies: - dompurify: 3.3.1 + dompurify: 3.4.14 '@types/estree-jsx@1.0.5': dependencies: @@ -6421,8 +6380,8 @@ snapshots: antd-style@4.1.0(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@ant-design/cssinjs': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@babel/runtime': 7.28.4 + '@ant-design/cssinjs': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@babel/runtime': 7.29.2 '@emotion/cache': 11.14.0 '@emotion/css': 11.13.5 '@emotion/react': 11.14.0(@types/react@19.2.7)(react@19.2.3) @@ -6537,7 +6496,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.11 @@ -6975,11 +6934,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.3.1: - optionalDependencies: - '@types/trusted-types': 2.0.7 - - dompurify@3.3.3: + dompurify@3.4.14: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8078,7 +8033,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.3.3 + dompurify: 3.4.14 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 9ce50bcd0a..94967e3692 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -33,6 +33,7 @@ export interface ChannelTimePricingPeriod { export interface ChannelTimePricing { timezone: string + weekdays_only?: boolean periods: ChannelTimePricingPeriod[] } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index dd976daad5..0616449f1b 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -35,6 +35,7 @@ import affiliatesAPI from './affiliates' import riskControlAPI from './riskControl' import adminComplianceAPI from './compliance' import auditAPI from './audit' +import pluginsAPI from './plugins' /** * Unified admin API object for convenient access @@ -71,7 +72,8 @@ export const adminAPI = { affiliates: affiliatesAPI, riskControl: riskControlAPI, compliance: adminComplianceAPI, - audit: auditAPI + audit: auditAPI, + plugins: pluginsAPI } export { @@ -106,7 +108,8 @@ export { affiliatesAPI, riskControlAPI, adminComplianceAPI, - auditAPI + auditAPI, + pluginsAPI } export default adminAPI @@ -118,3 +121,9 @@ export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from export type { BackupAgentHealth, DataManagementConfig } from './dataManagement' export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile' export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' +export type { + PluginInstallation, + PluginCompatibility, + PluginUISession, + PluginTestResult +} from './plugins' diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts new file mode 100644 index 0000000000..8b4489d41c --- /dev/null +++ b/frontend/src/api/admin/plugins.ts @@ -0,0 +1,156 @@ +import { apiClient } from '../client' + +export interface PluginCapability { + id: string + platform: string + account_type: string +} + +export interface PluginRequirements { + sub2api: string + recommended_sub2api_version?: string + tested_sub2api_versions?: string[] + plugin_protocol: number + transport_api: number + ui_bridge: number +} + +export interface PluginManifest { + schema_version: number + id: string + name: string + version: string + description?: string + author?: string + requires: PluginRequirements + capabilities: PluginCapability[] + ui: { entrypoint: string } +} + +export interface PluginCompatibility { + compatible: boolean + tested: boolean + status: 'compatible' | 'untested' | 'incompatible' + message: string + current_sub2api_version: string + required_sub2api_version: string + recommended_sub2api_version: string + plugin_protocol: number + transport_api: number + ui_bridge: number +} + +export interface PluginBinding { + id: number + plugin_id: number + capability: string + platform: string + account_type: string + enabled: boolean + rollout_percent: number +} + +export interface PluginInstallation { + id: number + plugin_key: string + name: string + version: string + description: string + author: string + manifest: PluginManifest + binary_sha256: string + signature_status: 'trusted' | 'unsigned' + state: 'disabled' | 'starting' | 'enabled' | 'error' | 'incompatible' + last_error: string + installed_at: string + enabled_at?: string + updated_at: string + bindings: PluginBinding[] + compatibility: PluginCompatibility + runtime_healthy: boolean + runtime_message: string +} + +export interface PluginTestResult { + success: boolean + message: string + latency_ms: number +} + +export interface PluginUISession { + url: string + bridge_token: string + ui_bridge_version: number + expires_at: string +} + +export async function list(): Promise { + const { data } = await apiClient.get('/admin/plugins') + return data +} + +export async function upload(file: File): Promise { + const form = new FormData() + form.append('plugin', file) + const { data } = await apiClient.post('/admin/plugins/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000 + }) + return data +} + +export async function enable( + id: number, + rolloutPercent: number, + acceptUntested: boolean +): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/enable`, { + rollout_percent: rolloutPercent, + accept_untested: acceptUntested + }) + return data +} + +export async function disable(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/disable`) + return data +} + +export async function remove(id: number): Promise { + await apiClient.delete(`/admin/plugins/${id}`) +} + +export async function getConfig(id: number): Promise> { + const { data } = await apiClient.get>(`/admin/plugins/${id}/config`) + return data +} + +export async function saveConfig( + id: number, + config: Record +): Promise> { + const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config) + return data +} + +export async function test(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/test`) + return data +} + +export async function createUISession(id: number): Promise { + const { data } = await apiClient.post(`/admin/plugins/${id}/ui-session`) + return data +} + +export default { + list, + upload, + enable, + disable, + remove, + getConfig, + saveConfig, + test, + createUISession +} diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index f5f19918a8..4ceb75cbd9 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -727,6 +727,7 @@ export interface SystemSettings { model_plaza_enabled: boolean; model_plaza_require_auth: boolean; model_plaza_description: string; + plugin_management_enabled: boolean; // Affiliate (邀请返利) feature switch affiliate_enabled: boolean; @@ -1026,6 +1027,7 @@ export interface UpdateSettingsRequest { model_plaza_enabled?: boolean; model_plaza_require_auth?: boolean; model_plaza_description?: string; + plugin_management_enabled?: boolean; // Affiliate (邀请返利) feature switch affiliate_enabled?: boolean; diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index c17b3aa1a7..e96a621934 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -5,9 +5,9 @@ */ import { apiClient } from './client' -import type { UserSupportedModelPricing } from './channels' +import type { UserPricingInterval, UserSupportedModelPricing } from './channels' -/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */ +/** 官方参考价(USD per token,与计费目录同源;字段缺失 = 目录未覆盖)。 */ export interface PlazaOfficialPricing { input_price: number | null output_price: number | null @@ -16,13 +16,43 @@ export interface PlazaOfficialPricing { /** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */ cache_write_1h_price?: number | null cache_read_price: number | null + /** 官方长上下文阶梯(多档模型才有),不受分组开关影响。 */ + intervals?: UserPricingInterval[] +} + +/** + * 多档时的计价基准: + * - whole_request:整单按所在档单价计价(目录阶梯、渠道区间); + * - marginal:仅超出阈值的部分按该档单价计价(平台旧规则)。 + */ +export type PlazaLongContextBasis = 'whole_request' | 'marginal' + +/** 分时倍率时段:配置时区当天 [start_time, end_time) 内整单实付乘 multiplier。 */ +export interface PlazaTimePricingPeriod { + start_time: string + end_time: string + multiplier: number +} + +/** 计费会生效的分时倍率(仅倍率 ≠ 1 的时段,已按开始时间升序)。 */ +export interface PlazaTimePricing { + /** IANA 时区名,如 Asia/Shanghai。 */ + timezone: string + /** true 时时段仅周一至周五生效,周末整天按标准价计费。 */ + weekdays_only?: boolean + periods: PlazaTimePricingPeriod[] } export interface PlazaModel { name: string platform: string + /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null + /** 仅多档模型返回。 */ + long_context_basis?: PlazaLongContextBasis + /** 仅配置了分时倍率的模型返回。 */ + time_pricing?: PlazaTimePricing } export interface ModelPlazaGroup { @@ -43,6 +73,8 @@ export interface ModelPlazaGroup { /** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */ image_rate_independent: boolean image_rate_multiplier: number + /** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */ + long_context_pricing_enabled: boolean models: PlazaModel[] } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 240e96fcb9..b1a6d10e97 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2266,6 +2266,66 @@ +
+
+
+ +

+ {{ t('admin.accounts.autoResetCredit.hint') }} +

+
+ +
+
+
+ + +
+
+ + +
+
+

{{ t('admin.accounts.autoResetCredit.thresholdHint') }}

+
+
(null) const autoPause7dThreshold = ref(null) const autoPause5hDisabled = ref(false) const autoPause7dDisabled = ref(false) +const autoResetCreditEnabled = ref(false) +const autoResetCredit5hThreshold = ref(100) +const autoResetCredit7dThreshold = ref(100) const upstreamBillingAutoProbeEnabled = ref(false) const upstreamBillingRateSyncEnabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling @@ -3626,6 +3689,11 @@ const syncFormFromAccount = (newAccount: Account | null) => { autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true + autoResetCreditEnabled.value = extra?.auto_reset_credit_enabled === true + autoResetCredit5hThreshold.value = + typeof extra?.auto_reset_credit_5h_threshold === 'number' ? extra.auto_reset_credit_5h_threshold * 100 : 100 + autoResetCredit7dThreshold.value = + typeof extra?.auto_reset_credit_7d_threshold === 'number' ? extra.auto_reset_credit_7d_threshold * 100 : 100 upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true upstreamBillingRateSyncEnabled.value = upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true @@ -4522,6 +4590,13 @@ const handleSubmit = async () => { appStore.showError(t('admin.accounts.pleaseSelectStatus')) return } + if (autoResetCreditEnabled.value) { + const thresholds = [autoResetCredit5hThreshold.value, autoResetCredit7dThreshold.value] + if (thresholds.some((value) => !Number.isFinite(value) || value < 0.1 || value > 100)) { + appStore.showError(t('admin.accounts.autoResetCredit.thresholdInvalid')) + return + } + } const updatePayload: Record = { ...form } try { @@ -5086,6 +5161,13 @@ const handleSubmit = async () => { } else { delete newExtra.auto_pause_7d_disabled } + if (props.account.type === 'oauth' && !isSparkShadow.value) { + newExtra.auto_reset_credit_enabled = autoResetCreditEnabled.value + newExtra.auto_reset_credit_5h_threshold = autoResetCredit5hThreshold.value / 100 + newExtra.auto_reset_credit_7d_threshold = autoResetCredit7dThreshold.value / 100 + } + // 运行态只允许后端服务更新,账号编辑不得回写旧状态。 + delete newExtra.codex_auto_reset_credit_state delete newExtra.codex_image_generation_bridge_enabled switch (codexImageToolMode.value) { diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index cb81bcfd9d..6a3225b8bf 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -63,6 +63,32 @@
+
+ + {{ autoResetStateLabel }} + + {{ autoResetState.trigger_window }} + + + + {{ formatResetCreditExpiry(autoResetState.checked_at, 'short') }} + + + {{ autoResetState.error_code }} + +
+
(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +type AutoResetCreditState = NonNullable['codex_auto_reset_credit_state']> +const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed']) +const autoResetState = computed(() => { + if (props.account.extra?.auto_reset_credit_enabled !== true) return null + const state = props.account.extra?.codex_auto_reset_credit_state + if (!state || typeof state !== 'object' || !validAutoResetStatuses.has(String(state.status))) return null + return state +}) +const autoResetStateLabel = computed(() => { + if (!autoResetState.value?.status) return '' + const keyByStatus: Record = { + checking: 'checking', + available: 'available', + resetting: 'resetting', + success: 'success', + no_credit: 'noCredit', + failed: 'failed' + } + return t(`admin.accounts.openaiQuotaReset.autoStatus.${keyByStatus[autoResetState.value.status]}`) +}) +const autoResetStateClass = computed(() => { + switch (autoResetState.value?.status) { + case 'available': + return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' + case 'success': + return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' + case 'no_credit': + case 'failed': + return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300' + case 'resetting': + return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' + default: + return 'bg-gray-100 text-gray-600 dark:bg-dark-800 dark:text-gray-300' + } +}) + // 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 diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index acd0436f6a..eceb670c92 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -290,6 +290,18 @@ function buildOpenAISetupTokenAccount() { } as any } +function buildOpenAIOAuthParentAccount() { + return { + ...buildAccount(), + id: 7, + name: 'OpenAI OAuth Parent', + type: 'oauth', + parent_account_id: null, + credentials: { access_token: 'oauth-token' }, + extra: {} + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -1351,3 +1363,63 @@ describe('EditAccountModal', () => { ) }) }) + +describe('EditAccountModal OpenAI 自动使用重置卡', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + }) + + it('仅对 OpenAI OAuth 母账号显示,默认关闭且阈值为 100/100', () => { + const parent = mountModal(buildOpenAIOAuthParentAccount()) + expect(parent.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(true) + expect((parent.get('[data-testid="auto-reset-credit-5h-threshold"]').element as HTMLInputElement).value).toBe('100') + expect((parent.get('[data-testid="auto-reset-credit-7d-threshold"]').element as HTMLInputElement).value).toBe('100') + expect(parent.get('[data-testid="auto-reset-credit-5h-threshold"]').attributes('disabled')).toBeDefined() + parent.unmount() + + for (const account of [buildAccount(), buildOpenAISetupTokenAccount(), buildOpenAISparkShadowAccount()]) { + const wrapper = mountModal(account) + expect(wrapper.find('[data-testid="auto-reset-credit-settings"]').exists()).toBe(false) + wrapper.unmount() + } + }) + + it('独立保存两个阈值,并禁止把运行态回写到管理请求', async () => { + const account = buildOpenAIOAuthParentAccount() + account.extra = { + codex_auto_reset_credit_state: { + status: 'success', + trigger_window: '5h', + available_count: 1 + } + } + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click') + await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('75.5') + await wrapper.get('[data-testid="auto-reset-credit-7d-threshold"]').setValue('92') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + const extra = updateAccountMock.mock.calls[0]?.[1]?.extra + expect(extra).toMatchObject({ + auto_reset_credit_enabled: true, + auto_reset_credit_5h_threshold: 0.755, + auto_reset_credit_7d_threshold: 0.92 + }) + expect(extra).not.toHaveProperty('codex_auto_reset_credit_state') + wrapper.unmount() + }) + + it('开启后拒绝超出 0.1–100 范围的任一阈值', async () => { + const wrapper = mountModal(buildOpenAIOAuthParentAccount()) + await wrapper.get('[data-testid="auto-reset-credit-enabled"]').trigger('click') + await wrapper.get('[data-testid="auto-reset-credit-5h-threshold"]').setValue('0') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock).not.toHaveBeenCalled() + wrapper.unmount() + }) +}) 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 f888cbee44..280725bc5b 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -344,3 +344,45 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { wrapper.unmount() }) }) + +describe('OpenAIQuotaResetCell 自动用卡运行态', () => { + it.each([ + ['checking', 'checking'], + ['available', 'available'], + ['resetting', 'resetting'], + ['success', 'success'], + ['no_credit', 'noCredit'], + ['failed', 'failed'], + ] as const)('展示 %s 状态且不需要暴露卡标识', (status, labelKey) => { + const account = makeAccount({ + extra: { + auto_reset_credit_enabled: true, + codex_auto_reset_credit_state: { + status, + trigger_window: '5h', + available_count: 1, + checked_at: '2099-07-03T04:05:06Z', + error_code: status === 'failed' ? 'RESET_FAILED' : undefined, + }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + const state = wrapper.get('[data-testid="auto-reset-credit-state"]') + expect(state.text()).toContain(`admin.accounts.openaiQuotaReset.autoStatus.${labelKey}`) + expect(state.text()).toContain('5h') + expect(state.text()).not.toContain('credit_id') + wrapper.unmount() + }) + + it('开关关闭时不显示历史运行态', () => { + const account = makeAccount({ + extra: { + auto_reset_credit_enabled: false, + codex_auto_reset_credit_state: { status: 'success', available_count: 1 }, + }, + }) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + expect(wrapper.find('[data-testid="auto-reset-credit-state"]').exists()).toBe(false) + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/admin/channel/TimePricingSection.vue b/frontend/src/components/admin/channel/TimePricingSection.vue index b2e31ca46d..ae329f3680 100644 --- a/frontend/src/components/admin/channel/TimePricingSection.vue +++ b/frontend/src/components/admin/channel/TimePricingSection.vue @@ -1,22 +1,40 @@