diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 859946525..b91734495 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,6 +16,7 @@ jobs: run: | /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh + /bin/sh deploy/test-caddyfile-cache.sh test: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 0e87f7599..e5b625e39 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,6 @@ Please read the following carefully before using this project: - -apikl -Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. Register now for a 1:1 top-up bonus — double your balance! - - - - -tokeneum -Thanks to TokenEum for sponsoring this project! TokenEum is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit tokeneum.ai to get started. - - - 666api Thanks to 666api for sponsoring this project! 666api is an all-in-one platform offering:
@@ -135,12 +123,6 @@ Please read the following carefully before using this project: - -miyaip -Thanks to MiyaIP for sponsoring this project! MiyaIP is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation. - - - anpin Thanks to anpin.ai for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.
diff --git a/README_CN.md b/README_CN.md index b03ba9311..5ccc48ab4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -105,18 +105,6 @@ - -apikl -感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。立即注册即享充值 1:1 赠送 — 余额翻倍! - - - - -tokeneum -感谢 TokenEum 赞助本项目!TokenEum 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 tokeneum.ai 开始使用。 - - - 666api 感谢 666api 赞助本项目!666api 是一站式综合服务平台,提供:
@@ -138,12 +126,6 @@ - -miyaip -感谢 MiyaIP 赞助本项目!MiyaIP 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证 - - - anpin 感谢 anpin.ai 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。
diff --git a/README_JA.md b/README_JA.md index 53546c0f2..a4fd86b84 100644 --- a/README_JA.md +++ b/README_JA.md @@ -104,18 +104,6 @@ - -apikl -Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。今すぐ登録でチャージ 1:1 ボーナス — 残高が倍に! - - - - -tokeneum -TokenEum のご支援に感謝します!TokenEum は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。tokeneum.ai でぜひお試しください。 - - - 666api 666api のご支援に感謝します!666api はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応 @@ -133,12 +121,6 @@ - -miyaip -MiyaIP のご支援に感謝します!MiyaIP はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。 - - - anpin anpin.ai のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。
diff --git a/assets/partners/logos/apikl.png b/assets/partners/logos/apikl.png deleted file mode 100644 index f98211ac0..000000000 Binary files a/assets/partners/logos/apikl.png and /dev/null differ diff --git a/assets/partners/logos/miyaip.png b/assets/partners/logos/miyaip.png deleted file mode 100644 index 3d484c945..000000000 Binary files a/assets/partners/logos/miyaip.png and /dev/null differ diff --git a/assets/partners/logos/tokeneum.png b/assets/partners/logos/tokeneum.png deleted file mode 100644 index 8ced15c81..000000000 Binary files a/assets/partners/logos/tokeneum.png and /dev/null differ diff --git a/backend/go.mod b/backend/go.mod index d9d5007d0..49a5d8da1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,7 +20,7 @@ require ( github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 - github.com/imroc/req/v3 v3.57.0 + github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible @@ -43,7 +43,7 @@ require ( github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 golang.org/x/crypto v0.53.0 - golang.org/x/image v0.39.0 + golang.org/x/image v0.41.0 golang.org/x/mod v0.37.0 golang.org/x/net v0.56.0 golang.org/x/sync v0.21.0 @@ -141,7 +141,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.57.1 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -166,12 +166,12 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - go.opentelemetry.io/auto/sdk 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.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.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.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -179,6 +179,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.46.0 // indirect 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 diff --git a/backend/go.sum b/backend/go.sum index bb33293b7..0218ed057 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -166,8 +166,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 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= @@ -185,8 +183,8 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= 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.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= -github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= +github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo= +github.com/imroc/req/v3 v3.59.0/go.mod h1:cwLwwaE90Pq6ZMmKuHA9FivJbg3avnCH8/RA5lUUs74= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -222,8 +220,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -257,8 +253,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -278,22 +272,22 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= -github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -322,8 +316,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= @@ -386,22 +378,22 @@ github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWB github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k= github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +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/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.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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/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= @@ -419,24 +411,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -448,22 +432,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a62c068f2..3bd071e9a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1640,20 +1640,7 @@ func LoadForBootstrap() (*Config, error) { func load(allowMissingJWTSecret bool) (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") - - // Add config paths in priority order - // 1. DATA_DIR environment variable (highest priority) - if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { - viper.AddConfigPath(dataDir) - } - // 2. Docker data directory - viper.AddConfigPath("/app/data") - // 3. Current directory - viper.AddConfigPath(".") - // 4. Config subdirectory - viper.AddConfigPath("./config") - // 5. System config directory - viper.AddConfigPath("/etc/sub2api") + configureConfigSource(viper.SetConfigFile, viper.AddConfigPath) // 环境变量支持 viper.AutomaticEnv() @@ -1828,6 +1815,22 @@ func load(allowMissingJWTSecret bool) (*Config, error) { return &cfg, nil } +func configureConfigSource(setConfigFile, addConfigPath func(string)) { + if configFile := strings.TrimSpace(os.Getenv("CONFIG_FILE")); configFile != "" { + setConfigFile(configFile) + return + } + + // Add config paths in priority order. + if dataDir := strings.TrimSpace(os.Getenv("DATA_DIR")); dataDir != "" { + addConfigPath(dataDir) + } + addConfigPath("/app/data") + addConfigPath(".") + addConfigPath("./config") + addConfigPath("/etc/sub2api") +} + func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) @@ -3488,14 +3491,12 @@ func generateJWTSecret(byteLength int) (string, error) { // GetServerAddress returns the server address (host:port) from config file or environment variable. // This is a lightweight function that can be used before full config validation, // such as during setup wizard startup. -// Priority: config.yaml > environment variables > defaults +// Priority: environment variables > config file > defaults. func GetServerAddress() string { v := viper.New() v.SetConfigName("config") v.SetConfigType("yaml") - v.AddConfigPath(".") - v.AddConfigPath("./config") - v.AddConfigPath("/etc/sub2api") + configureConfigSource(v.SetConfigFile, v.AddConfigPath) // Support SERVER_HOST and SERVER_PORT environment variables v.AutomaticEnv() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index e4186732a..f67fbb4cc 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -17,6 +17,9 @@ import ( func resetViperWithJWTSecret(t *testing.T) { t.Helper() viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } @@ -253,8 +256,53 @@ func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) { } } +func TestLoadTrustedProxiesFromConfigFile(t *testing.T) { + resetViperWithJWTSecret(t) + configFile := filepath.Join(t.TempDir(), "reporter-config.yaml") + require.NoError(t, os.WriteFile(configFile, []byte(`server: + host: 192.0.2.10 + trusted_proxies: + - 127.0.0.1/32 + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 +`), 0o600)) + t.Setenv("CONFIG_FILE", configFile) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "192.0.2.10", cfg.Server.Host) + require.Equal(t, []string{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestConfigFileTakesPrecedenceOverDataDir(t *testing.T) { + resetViperWithJWTSecret(t) + dataDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("server:\n host: 192.0.2.20\n"), 0o600)) + configFile := filepath.Join(t.TempDir(), "explicit.yaml") + require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.30\n"), 0o600)) + t.Setenv("DATA_DIR", dataDir) + t.Setenv("CONFIG_FILE", configFile) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "192.0.2.30", cfg.Server.Host) +} + +func TestLoadReturnsErrorForMissingConfigFile(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("CONFIG_FILE", filepath.Join(t.TempDir(), "missing.yaml")) + + _, err := Load() + require.ErrorContains(t, err, "read config error") +} + func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", "") cfg, err := LoadForBootstrap() @@ -1155,6 +1203,8 @@ func TestNormalizeStringSlice(t *testing.T) { } func TestGetServerAddressFromEnv(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("SERVER_HOST", "127.0.0.1") t.Setenv("SERVER_PORT", "9090") @@ -1164,6 +1214,17 @@ func TestGetServerAddressFromEnv(t *testing.T) { } } +func TestGetServerAddressFromConfigFile(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "setup.yaml") + require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.40\n port: 9191\n"), 0o600)) + t.Setenv("CONFIG_FILE", configFile) + t.Setenv("DATA_DIR", "") + t.Setenv("SERVER_HOST", "") + t.Setenv("SERVER_PORT", "") + + require.Equal(t, "192.0.2.40:9191", GetServerAddress()) +} + func TestValidateAbsoluteHTTPURL(t *testing.T) { if err := ValidateAbsoluteHTTPURL("https://example.com/path"); err != nil { t.Fatalf("ValidateAbsoluteHTTPURL valid url error: %v", err) diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go new file mode 100644 index 000000000..e39e664a3 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -0,0 +1,67 @@ +//go:build unit + +package admin + +import ( + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/stretchr/testify/require" +) + +// Saving settings is a whole-document PUT. A client that sends only the field it +// cares about must not reset everything else: a payload as small as +// `{"risk_control_enabled":true}` used to clear site_name, after which +// getStringOrDefault rendered the empty value as the built-in default and the +// login page silently changed name. + +func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySiteName: "Example Gateway", + service.SettingKeySiteSubtitle: "Example Gateway Platform", + service.SettingKeySMTPHost: "smtp.example.com", + service.SettingKeySMTPFrom: "noreply@example.com", + service.SettingKeyTurnstileEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "true", repo.values[service.SettingKeyRiskControlEnabled], + "the field the caller actually sent must be written") + + require.Equal(t, "Example Gateway", repo.values[service.SettingKeySiteName]) + require.Equal(t, "Example Gateway Platform", repo.values[service.SettingKeySiteSubtitle]) + require.Equal(t, "smtp.example.com", repo.values[service.SettingKeySMTPHost]) + require.Equal(t, "noreply@example.com", repo.values[service.SettingKeySMTPFrom]) + require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled]) +} + +// A full payload keeps whole-document semantics: fields explicitly set to their +// zero value are still cleared. +func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySiteName: "Example Gateway", + }) + + rec := doUpdateSettings(t, h, map[string]any{"site_name": ""}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "", repo.values[service.SettingKeySiteName], + "an explicitly sent empty value is a deliberate clear, not an omission") +} + +// smtp_from_email is the one request field whose JSON name differs from its +// setting key; the alias keeps it from being treated as always-omitted. +func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySMTPFrom: "old@example.com", + }) + + rec := doUpdateSettings(t, h, map[string]any{"smtp_from_email": "new@example.com"}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom]) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index f852bb7e6..ec910b525 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "reflect" "strings" "github.com/Wei-Shaw/sub2api/internal/config" @@ -15,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) // UpdateSettingsRequest 更新设置请求 @@ -375,12 +377,70 @@ func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool { return true } +// settingKeyJSONAliases covers the request fields whose JSON name differs from +// the setting key they persist to. Every other field of UpdateSettingsRequest +// is named after its setting key. +var settingKeyJSONAliases = map[string]string{ + "smtp_from_email": service.SettingKeySMTPFrom, +} + +// settingKeyByJSONName maps the value-typed top-level JSON fields of +// UpdateSettingsRequest to the setting key each one writes. Resolved once from +// the struct tags so new fields are covered without touching this file. +// +// Pointer-typed fields are deliberately excluded: they already carry their own +// "omitted = keep the stored value" merge in UpdateSettings, and some of them +// rely on being rewritten on every save to re-normalize fail-closed security +// state (see TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted). +// Only the value-typed fields are indistinguishable from a deliberate clear. +var settingKeyByJSONName = buildSettingKeyByJSONName() + +func buildSettingKeyByJSONName() map[string]string { + t := reflect.TypeOf(UpdateSettingsRequest{}) + out := make(map[string]string, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Type.Kind() == reflect.Ptr { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + if alias, ok := settingKeyJSONAliases[name]; ok { + out[name] = alias + continue + } + out[name] = name + } + return out +} + +// omittedSettingKeys reports the setting keys this payload never mentioned. +// Saving settings is a whole-document PUT, so without this a client that sends +// only the one field it cares about resets every other field to a zero value. +func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSettingKeys { + omitted := make(service.OmittedSettingKeys, len(settingKeyByJSONName)) + for jsonName, settingKey := range settingKeyByJSONName { + if _, sent := sentFields[jsonName]; !sent { + omitted[settingKey] = struct{}{} + } + } + return omitted +} + func (h *SettingHandler) UpdateSettings(c *gin.Context) { - var req UpdateSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { + var sentFields map[string]json.RawMessage + if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + var req UpdateSettingsRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + omitted := omittedSettingKeys(sentFields) previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) if err != nil { @@ -1692,7 +1752,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { }, ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), } - if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { + if err := h.settingService.UpdateSettingsWithAuthSourceDefaultsOmitting(c.Request.Context(), settings, authSourceDefaults, omitted); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index a7e0156d1..ad2bbecd2 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -110,6 +110,7 @@ func (h *UsageHandler) List(c *gin.Context) { } model := c.Query("model") + requestID := strings.TrimSpace(c.Query("request_id")) billingMode := strings.TrimSpace(c.Query("billing_mode")) var requestType *int16 @@ -176,6 +177,7 @@ func (h *UsageHandler) List(c *gin.Context) { APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestID: requestID, Model: model, ModelFilterSource: usagestats.ModelSourceRequested, RequestType: requestType, diff --git a/backend/internal/handler/admin/usage_handler_request_type_test.go b/backend/internal/handler/admin/usage_handler_request_type_test.go index bb4d43b4b..7ea657749 100644 --- a/backend/internal/handler/admin/usage_handler_request_type_test.go +++ b/backend/internal/handler/admin/usage_handler_request_type_test.go @@ -107,6 +107,18 @@ func TestAdminUsageListExactTotalTrue(t *testing.T) { require.True(t, repo.listFilters.ExactTotal) } +func TestAdminUsageListRequestIDFilter(t *testing.T) { + repo := &adminUsageRepoCapture{} + router := newAdminUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_id=req-0123", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "req-0123", repo.listFilters.RequestID) +} + func TestAdminUsageListInvalidExactTotal(t *testing.T) { repo := &adminUsageRepoCapture{} router := newAdminUsageRequestTypeTestRouter(repo) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 9b550ffa7..773a87a86 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "strconv" "strings" + "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" @@ -43,6 +44,49 @@ type OpenAIGatewayHandler struct { cfg *config.Config } +type openAIWSTurnChannelMappingSnapshot struct { + turn int + mapping service.ChannelMappingResult +} + +var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch") + +func newOpenAIWSUnsupportedModelSwitchError(model string) error { + cause := fmt.Errorf("%w: model %q", errOpenAIWSUnsupportedModelSwitch, strings.TrimSpace(model)) + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "model switch requires reconnect", cause) +} + +func shouldReportOpenAIWSProxyAccountFailure(err error) bool { + return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) +} + +func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string { + billingModel := "" + if result != nil { + billingModel = strings.TrimSpace(result.BillingModel) + } + if billingModel == "" { + billingModel = strings.TrimSpace(upstreamModel) + } + if billingModel == "" { + billingModel = strings.TrimSpace(requestedModel) + } + + requestedModel = strings.TrimSpace(requestedModel) + switch mapping.BillingModelSource { + case service.BillingModelSourceRequested: + if requestedModel != "" { + billingModel = requestedModel + } + case service.BillingModelSourceChannelMapped: + mappedModel := strings.TrimSpace(mapping.MappedModel) + if mappedModel != "" && mappedModel != requestedModel { + billingModel = mappedModel + } + } + return billingModel +} + type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } @@ -386,6 +430,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState + var passthroughFailoverState openAIPassthroughFailoverState // 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则 // 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。 @@ -482,13 +527,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应, // 不能因心跳字节变化而放弃 failover 换号(#3887)。 writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) + // 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前, + // 从不可变的 canonical forwardBody 派生本次尝试 body 并整块剔除上游私有的加密 + // reasoning item(含耦合的 id/summary),避免非透传上游 400 拒绝 Kiro reasoning 形态。 + attemptBody := h.deriveOpenAIForwardAttemptBody(reqLog, forwardBody, account, &passthroughFailoverState) result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } }() - return h.gatewayService.Forward(c.Request.Context(), c, account, forwardBody) + return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody) }() cyberBlockKeyHTTP := "" if service.GetOpsCyberPolicy(c) != nil { @@ -1519,7 +1568,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs reqLog = reqLog.With( zap.Bool("ws_ingress", true), - zap.String("model", reqModel), + zap.String("session_initial_model", reqModel), zap.Bool("has_previous_response_id", previousResponseID != ""), zap.String("previous_response_id_kind", previousResponseIDKind), ) @@ -1771,6 +1820,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings } var requestPayloadHash string + // Passthrough rejects overlapping response.create frames, so one immutable + // turn-tagged slot preserves the exact mapping used for the in-flight request. + var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] + turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) hooks := &service.OpenAIWSIngressHooks{ InitialRequestModel: reqModel, MaxReasoningEffort: maxReasoningEffort, @@ -1795,6 +1848,22 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } return nil }, + MapRequestModel: func(turn int, originalModel string) (string, error) { + model := strings.TrimSpace(originalModel) + if model == "" { + model = reqModel + } + mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) + mappedModelUnchanged := false + if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { + mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) + } + if turn > 1 && !mappedModelUnchanged && !account.IsModelSupported(model) && !account.IsModelSupported(mapping.MappedModel) { + return "", newOpenAIWSUnsupportedModelSwitchError(mapping.MappedModel) + } + turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: turn, mapping: mapping}) + return mapping.MappedModel, nil + }, BeforeTurn: func(turn int) error { // turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。 if cyberBlockedThisConn { @@ -1836,7 +1905,27 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 届时 defer 已清除标记)。 defer clearCyberPolicyTurnState(c) releaseTurnSlots() - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, turnErr != nil, cyberBlockKey, clientRequestedUsageFields(c, channelMappingWS, reqModel, ""), requestPayloadHash) + turnRequestedModel := reqModel + turnUpstreamModel := "" + if result != nil && turn > 1 { + if model := strings.TrimSpace(result.Model); model != "" { + turnRequestedModel = model + } + } + if result != nil { + turnUpstreamModel = strings.TrimSpace(result.UpstreamModel) + } + var turnMapping service.ChannelMappingResult + if snapshot := turnChannelMapping.Load(); snapshot != nil && snapshot.turn == turn { + turnMapping = snapshot.mapping + } else { + turnMapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, turnRequestedModel) + } + if turnUpstreamModel == "" { + turnUpstreamModel = turnRequestedModel + } + turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockKey, turnUsageFields, requestPayloadHash) if service.GetOpsCyberPolicy(c) != nil { cyberBlockedThisConn = true } @@ -1858,11 +1947,22 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if result == nil { return } + result.BillingModel = openAIWSTurnBillingModel(result, turnMapping, turnRequestedModel, turnUpstreamModel) + reqLog.Debug("openai.websocket_turn_billing", + zap.Int("turn", turn), + zap.String("turn_requested_model", turnRequestedModel), + zap.String("turn_upstream_model", turnUpstreamModel), + zap.String("billing_model", result.BillingModel), + ) // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) + scheduleModel := turnUpstreamModel + if scheduleModel == "" { + scheduleModel = turnRequestedModel + } + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) @@ -1883,7 +1983,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMappingWS, reqModel, result.UpstreamModel), + ChannelUsageFields: turnUsageFields, CyberBlocked: cyberBlocked, }); err != nil { reqLog.Error("openai.websocket_record_usage_failed", @@ -1896,11 +1996,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, } - // 应用渠道模型映射到 WebSocket 首条消息 wsFirstMessage := firstMessage - if channelMappingWS.Mapped { - wsFirstMessage = h.gatewayService.ReplaceModelInBody(firstMessage, channelMappingWS.MappedModel) - } // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 @@ -1944,7 +2040,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + if shouldReportOpenAIWSProxyAccountFailure(err) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ zap.Int64("account_id", account.ID), diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index eace1c7c9..abf81b79e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -1369,6 +1370,229 @@ func TestOpenAIResponsesWebSocket_PassthroughUsageLogLeavesUserAgentNilWhenMissi require.Equal(t, "medium", *got.log.ReasoningEffort) } +func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"terra","stream":false}`, + channelMapping: map[string]string{ + "sol": "sol-channel", + "terra": "terra-channel", + }, + accountModelMapping: map[string]any{ + "sol": "gpt-5.6-sol", + "terra": "gpt-5.6-terra", + "sol-channel": "gpt-5.6-sol", + "terra-channel": "gpt-5.6-terra", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "sol", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "terra", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "sol", got.logs[0].Model) + require.Equal(t, "sol", got.logs[0].RequestedModel) + require.NotNil(t, got.logs[0].UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel) + require.NotNil(t, got.logs[0].ModelMappingChain) + require.Equal(t, "sol→sol-channel→gpt-5.6-sol", *got.logs[0].ModelMappingChain) + + require.Equal(t, "terra", got.logs[1].Model) + require.Equal(t, "terra", got.logs[1].RequestedModel) + require.NotNil(t, got.logs[1].UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain) + require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + "each turn must be billed with its own channel-mapped model") +} + +func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`, + secondPayload: `{"type":"response.create","stream":false}`, + channelMapping: map[string]string{ + "public-alias": "gpt-5.6-sol", + }, + accountModelMapping: map[string]any{ + "public-alias": "gpt-5.6-terra", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + require.Len(t, got.logs, 2) + for _, usageLog := range got.logs { + require.Equal(t, "public-alias", usageLog.RequestedModel) + require.NotNil(t, usageLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageLog.UpstreamModel) + require.NotNil(t, usageLog.ModelMappingChain) + require.Equal(t, "public-alias→gpt-5.6-sol", *usageLog.ModelMappingChain) + } +} + +func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"sol","stream":false}`, + channelMapping: map[string]string{ + "sol": "gpt-5.6-sol", + }, + afterFirstUpstreamRequest: func(channelSvc *service.ChannelService) error { + if channelSvc == nil { + return errors.New("channel service is nil") + } + _, err := channelSvc.Update(context.Background(), 7701, &service.UpdateChannelInput{ + ModelMapping: map[string]map[string]string{ + service.PlatformOpenAI: {"sol": "gpt-5.6-terra"}, + }, + }) + return err + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "sol", got.logs[0].Model) + require.NotNil(t, got.logs[0].UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel) + require.NotNil(t, got.logs[0].ModelMappingChain) + require.Equal(t, "sol→gpt-5.6-sol", *got.logs[0].ModelMappingChain) + require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12, + "the in-flight turn must retain the channel-mapped billing model used when it was sent") + + require.Equal(t, "sol", got.logs[1].Model) + require.NotNil(t, got.logs[1].UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain) + require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + "the next turn must use the updated channel mapping") +} + +func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesRequestedModel(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.6-sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-5.6-terra","stream":false}`, + ingressMode: service.OpenAIWSIngressModeCtxPool, + billingModelSource: service.BillingModelSourceRequested, + channelMapping: map[string]string{ + "gpt-5.6-terra": "gpt-5.6-sol", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "gpt-5.6-sol", got.logs[0].RequestedModel) + require.Nil(t, got.logs[0].ModelMappingChain) + require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12) + require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain) + require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12, + "BillingModelSourceRequested must use the client model before channel mapping") +} + +func TestOpenAIWSTurnBillingModelPreservesImagePricingModel(t *testing.T) { + tests := []struct { + name string + resultModel string + mapping service.ChannelMappingResult + requestedModel string + upstreamModel string + wantBillingModel string + }{ + { + name: "upstream billing preserves image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream}, + requestedModel: "gpt-5.6-sol", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-image-2", + }, + { + name: "unmapped channel preserves image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-sol", BillingModelSource: service.BillingModelSourceChannelMapped}, + requestedModel: "gpt-5.6-sol", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-image-2", + }, + { + name: "requested source overrides image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceRequested}, + requestedModel: "public-image-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "public-image-alias", + }, + { + name: "mapped channel source overrides image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{MappedModel: "priced-channel-model", BillingModelSource: service.BillingModelSourceChannelMapped}, + requestedModel: "public-image-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "priced-channel-model", + }, + { + name: "text turn falls back to upstream model", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream}, + requestedModel: "public-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-5.6-sol", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &service.OpenAIForwardResult{BillingModel: tt.resultModel} + require.Equal(t, tt.wantBillingModel, openAIWSTurnBillingModel(result, tt.mapping, tt.requestedModel, tt.upstreamModel)) + }) + } +} + +func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { + t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { + err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) + require.False(t, shouldReportOpenAIWSProxyAccountFailure(err)) + + var closeErr *service.OpenAIWSClientCloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Equal(t, "model switch requires reconnect", closeErr.Reason()) + }) + + t.Run("upstream policy violation still penalizes account", func(t *testing.T) { + err := service.NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "upstream websocket authentication failed", + errors.New("upstream rejected credentials"), + ) + require.True(t, shouldReportOpenAIWSProxyAccountFailure(err)) + }) + + t.Run("generic proxy failure still penalizes account", func(t *testing.T) { + require.True(t, shouldReportOpenAIWSProxyAccountFailure(errors.New("upstream websocket read failed"))) + }) +} + func TestSetOpenAIClientTransportHTTP(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1516,14 +1740,22 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject } type openAIResponsesWSUsageLogCase struct { - firstPayload string - userAgent *string - channelMapping map[string]string + firstPayload string + secondPayload string + userAgent *string + ingressMode string + channelMapping map[string]string + billingModelSource string + accountModelMapping map[string]any + afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error } type openAIResponsesWSUsageLogResult struct { log *service.UsageLog + logs []*service.UsageLog upstreamFirstPayload []byte + upstreamPayloads [][]byte + clientEvents [][]byte } type openAIWSUsageHandlerAccountRepoStub struct { @@ -1633,12 +1865,50 @@ func (s *openAIWSUsageHandlerUsageLogRepoStub) Create(ctx context.Context, log * type openAIWSUsageHandlerChannelRepoStub struct { service.ChannelRepository + mu sync.Mutex channels []service.Channel groupPlatforms map[int64]string } func (s *openAIWSUsageHandlerChannelRepoStub) ListAll(ctx context.Context) ([]service.Channel, error) { - return s.channels, nil + s.mu.Lock() + defer s.mu.Unlock() + out := make([]service.Channel, 0, len(s.channels)) + for i := range s.channels { + out = append(out, *s.channels[i].Clone()) + } + return out, nil +} + +func (s *openAIWSUsageHandlerChannelRepoStub) GetByID(ctx context.Context, id int64) (*service.Channel, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.channels { + if s.channels[i].ID == id { + return s.channels[i].Clone(), nil + } + } + return nil, service.ErrChannelNotFound +} + +func (s *openAIWSUsageHandlerChannelRepoStub) Update(ctx context.Context, channel *service.Channel) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.channels { + if s.channels[i].ID == channel.ID { + s.channels[i] = *channel.Clone() + return nil + } + } + return service.ErrChannelNotFound +} + +func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupIDs(ctx context.Context, channelID int64) ([]int64, error) { + channel, err := s.GetByID(ctx, channelID) + if err != nil { + return nil, err + } + return append([]int64(nil), channel.GroupIDs...), nil } func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) { @@ -2147,8 +2417,13 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU t.Helper() gin.SetMode(gin.TestMode) - upstreamPayloadCh := make(chan []byte, 1) + turnCount := 1 + if strings.TrimSpace(tc.secondPayload) != "" { + turnCount = 2 + } + upstreamPayloadCh := make(chan []byte, turnCount) upstreamErrCh := make(chan error, 1) + var channelSvc *service.ChannelService upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, @@ -2161,29 +2436,39 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU _ = conn.CloseNow() }() - readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) - msgType, payload, readErr := conn.Read(readCtx) - cancelRead() - if readErr != nil { - upstreamErrCh <- readErr - return - } - if msgType != coderws.MessageText && msgType != coderws.MessageBinary { - upstreamErrCh <- errors.New("unexpected upstream websocket message type") - return - } - upstreamPayloadCh <- payload + for turn := 1; turn <= turnCount; turn++ { + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + msgType, payload, readErr := conn.Read(readCtx) + cancelRead() + if readErr != nil { + upstreamErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + upstreamErrCh <- errors.New("unexpected upstream websocket message type") + return + } + upstreamPayloadCh <- payload + if turn == 1 && tc.afterFirstUpstreamRequest != nil { + if callbackErr := tc.afterFirstUpstreamRequest(channelSvc); callbackErr != nil { + upstreamErrCh <- callbackErr + return + } + } - writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second) - writeErr := conn.Write(writeCtx, coderws.MessageText, []byte( - `{"type":"response.completed","response":{"id":"resp_usage_e2e","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}}`, - )) - cancelWrite() - if writeErr != nil { - upstreamErrCh <- writeErr - return + response := fmt.Sprintf( + `{"type":"response.completed","response":{"id":"resp_usage_e2e_%d","model":%q,"usage":{"input_tokens":2,"output_tokens":1}}}`, + turn, + gjson.GetBytes(payload, "model").String(), + ) + writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second) + writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(response)) + cancelWrite() + if writeErr != nil { + upstreamErrCh <- writeErr + return + } } - _ = conn.Close(coderws.StatusNormalClosure, "done") upstreamErrCh <- nil })) defer upstreamServer.Close() @@ -2198,14 +2483,18 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "api_key": "sk-test", - "base_url": upstreamServer.URL, + "api_key": "sk-test", + "base_url": upstreamServer.URL, + "model_mapping": tc.accountModelMapping, }, Extra: map[string]any{ "openai_apikey_responses_websockets_v2_enabled": true, "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, }, } + if strings.TrimSpace(tc.ingressMode) != "" { + account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode + } cfg := &config.Config{} cfg.RunMode = config.RunModeSimple @@ -2221,17 +2510,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account} - usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 1)} + usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, turnCount)} - var channelSvc *service.ChannelService if len(tc.channelMapping) > 0 { channelSvc = service.NewChannelService(&openAIWSUsageHandlerChannelRepoStub{ channels: []service.Channel{{ - ID: 7701, - Name: "openai-ws-e2e-channel", - Status: service.StatusActive, - GroupIDs: []int64{groupID}, - ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping}, + ID: 7701, + Name: "openai-ws-e2e-channel", + Status: service.StatusActive, + GroupIDs: []int64{groupID}, + ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping}, + BillingModelSource: tc.billingModelSource, }}, groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI}, }, nil, nil, nil) @@ -2314,26 +2603,44 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cancelWrite() require.NoError(t, err) - readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) - _, event, err := clientConn.Read(readCtx) - cancelRead() - require.NoError(t, err) - require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + clientEvents := make([][]byte, 0, turnCount) + readCompleted := func() { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + clientEvents = append(clientEvents, append([]byte(nil), event...)) + } + readCompleted() + if turnCount == 2 { + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload)) + cancelWrite() + require.NoError(t, err) + readCompleted() + } _ = clientConn.Close(coderws.StatusNormalClosure, "done") - var usageLog *service.UsageLog - select { - case usageLog = <-usageRepo.created: - require.NotNil(t, usageLog) - case <-time.After(3 * time.Second): - t.Fatal("等待 WebSocket usage log 写入超时") + usageLogs := make([]*service.UsageLog, 0, turnCount) + for len(usageLogs) < turnCount { + select { + case usageLog := <-usageRepo.created: + require.NotNil(t, usageLog) + usageLogs = append(usageLogs, usageLog) + case <-time.After(3 * time.Second): + t.Fatal("等待 WebSocket usage log 写入超时") + } } - var upstreamFirstPayload []byte - select { - case upstreamFirstPayload = <-upstreamPayloadCh: - case <-time.After(3 * time.Second): - t.Fatal("等待上游 WebSocket 首帧超时") + upstreamPayloads := make([][]byte, 0, turnCount) + for len(upstreamPayloads) < turnCount { + select { + case payload := <-upstreamPayloadCh: + upstreamPayloads = append(upstreamPayloads, payload) + case <-time.After(3 * time.Second): + t.Fatal("等待上游 WebSocket 请求帧超时") + } } select { @@ -2344,8 +2651,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } return openAIResponsesWSUsageLogResult{ - log: usageLog, - upstreamFirstPayload: upstreamFirstPayload, + log: usageLogs[0], + logs: usageLogs, + upstreamFirstPayload: upstreamPayloads[0], + upstreamPayloads: upstreamPayloads, + clientEvents: clientEvents, } } diff --git a/backend/internal/handler/openai_gateway_reasoning_failover.go b/backend/internal/handler/openai_gateway_reasoning_failover.go new file mode 100644 index 000000000..fecba8033 --- /dev/null +++ b/backend/internal/handler/openai_gateway_reasoning_failover.go @@ -0,0 +1,62 @@ +package handler + +import ( + "github.com/Wei-Shaw/sub2api/internal/service" + "go.uber.org/zap" +) + +// openAIPassthroughFailoverState tracks whether this forwarding loop has attempted +// an OpenAI passthrough account. Once it has, every subsequent non-passthrough +// attempt must use a sanitized body because the immutable canonical body can carry +// provider-specific encrypted reasoning produced by that passthrough upstream. +type openAIPassthroughFailoverState struct { + passthroughSeen bool +} + +// deriveOpenAIForwardAttemptBody returns the request body for the upcoming forward +// attempt against account. It always derives from the immutable canonical body and +// only removes encrypted reasoning input item(s) when the loop has already attempted +// a passthrough account and the upcoming account is non-passthrough. This remains +// sticky across retries and additional non-passthrough accounts. Attempts before +// any passthrough account, and all passthrough attempts, forward the canonical body +// unchanged. The canonical slice is never mutated. +// +// This method is invoked exactly once per forward attempt, immediately before the +// Forward call, and advances the failover state as a side effect. +func (h *OpenAIGatewayHandler) deriveOpenAIForwardAttemptBody( + reqLog *zap.Logger, + canonicalBody []byte, + account *service.Account, + state *openAIPassthroughFailoverState, +) []byte { + currentPassthrough := account.IsOpenAIPassthroughEnabled() + if currentPassthrough { + state.passthroughSeen = true + return canonicalBody + } + if !state.passthroughSeen { + return canonicalBody + } + + sanitized, changed, err := service.SanitizeOpenAICrossModeFailoverReasoning(canonicalBody) + if err != nil { + if reqLog != nil { + reqLog.Warn("openai.failover_cross_mode_reasoning_sanitize_failed", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + } + return canonicalBody + } + if !changed { + return canonicalBody + } + if reqLog != nil { + reqLog.Info("openai.failover_cross_mode_reasoning_stripped", + zap.Int64("account_id", account.ID), + zap.Bool("account_passthrough", currentPassthrough), + zap.Bool("passthrough_seen", true), + ) + } + return sanitized +} diff --git a/backend/internal/handler/openai_gateway_reasoning_failover_test.go b/backend/internal/handler/openai_gateway_reasoning_failover_test.go new file mode 100644 index 000000000..321364cd4 --- /dev/null +++ b/backend/internal/handler/openai_gateway_reasoning_failover_test.go @@ -0,0 +1,146 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// kiroReasoningCanonicalBody mirrors a native OpenAI Responses request whose input +// carries a provider-specific encrypted reasoning item (Kiro shape: encrypted_content +// coupled with an id and summary), plus surrounding message items. +const kiroReasoningCanonicalBody = `{"model":"gpt-5.1","stream":false,"input":[` + + `{"type":"message","role":"user","content":"hello"},` + + `{"type":"reasoning","id":"rs_kiro_abc123","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]},` + + `{"type":"message","role":"assistant","content":"hi"}` + + `]}` + +func newOpenAIPassthroughAccount(id int64, passthrough bool) *service.Account { + extra := map[string]any{"openai_passthrough": passthrough} + return &service.Account{ + ID: id, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Extra: extra, + } +} + +func reasoningItemCount(t *testing.T, body []byte) int { + t.Helper() + count := 0 + gjson.GetBytes(body, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "reasoning" { + count++ + } + return true + }) + return count +} + +// TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning drives the real +// account-switch/request-body seam used inside OpenAIGatewayHandler.Responses: the +// same deriveOpenAIForwardAttemptBody call the failover loop makes, fed real +// *service.Account values in sequence. It proves the first passthrough attempt gets +// the untouched reasoning item, and after a failover to a non-passthrough account +// the second attempt body has the entire reasoning item removed — while the +// immutable canonical body is never mutated. +func TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + + kiro := newOpenAIPassthroughAccount(1, true) // Kiro: openai_passthrough=true + bedrock := newOpenAIPassthroughAccount(2, false) // Bedrock Mantle: passthrough disabled + + state := &openAIPassthroughFailoverState{} + + // Attempt 1 — passthrough (Kiro): original reasoning item must survive intact. + firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt must keep the reasoning item") + require.Equal(t, "ENC_BLOB", gjson.GetBytes(firstBody, "input.1.encrypted_content").String()) + require.Equal(t, "rs_kiro_abc123", gjson.GetBytes(firstBody, "input.1.id").String()) + require.JSONEq(t, kiroReasoningCanonicalBody, string(firstBody), "first attempt body must equal the canonical body") + + // Attempt 2 — failover switches to non-passthrough (Bedrock): the whole + // provider-specific encrypted reasoning item (id/summary/encrypted_content) is dropped. + secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state) + require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt must drop the reasoning item entirely") + require.False(t, gjson.GetBytes(secondBody, "input.#(encrypted_content)").Exists(), "no encrypted_content may remain") + require.NotContains(t, string(secondBody), "rs_kiro_abc123", "coupled reasoning id must be gone") + require.NotContains(t, string(secondBody), "ENC_BLOB") + + // Surviving message items are preserved. + require.Equal(t, 2, int(gjson.GetBytes(secondBody, "input.#").Int())) + require.Equal(t, "hello", gjson.GetBytes(secondBody, "input.0.content").String()) + require.Equal(t, "hi", gjson.GetBytes(secondBody, "input.1.content").String()) + + // The canonical body is immutable across attempts. + require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated") +} + +// TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning proves that +// non-passthrough attempts before any passthrough attempt, passthrough-family +// failovers, and switching into passthrough forward the canonical reasoning item +// unchanged. +func TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + + t.Run("non_passthrough_to_non_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(10, false), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(11, false), state) + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 1, reasoningItemCount(t, second), "Bedrock-family failover must preserve reasoning") + require.JSONEq(t, kiroReasoningCanonicalBody, string(second)) + }) + + t.Run("passthrough_to_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(20, true), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(21, true), state) + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 1, reasoningItemCount(t, second), "Kiro-family failover must preserve reasoning") + }) + + t.Run("non_passthrough_to_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(30, false), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(31, true), state) + require.Equal(t, 1, reasoningItemCount(t, second), "switching into passthrough must preserve reasoning") + }) + + t.Run("same_account_pool_retry", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + kiro := newOpenAIPassthroughAccount(40, true) + _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, retry), "same-account retry must preserve reasoning") + }) +} + +// TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries proves +// that once a passthrough account has been attempted, all later non-passthrough +// retries and failovers remain sanitized instead of restoring the canonical Kiro +// reasoning item after the first Bedrock failure. +func TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + state := &openAIPassthroughFailoverState{} + + kiro := newOpenAIPassthroughAccount(50, true) + bedrockA := newOpenAIPassthroughAccount(51, false) + bedrockB := newOpenAIPassthroughAccount(52, false) + + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state) + retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state) + nextAccount := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockB, state) + + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 0, reasoningItemCount(t, second)) + require.Equal(t, 0, reasoningItemCount(t, retry), "same Bedrock account retry must remain sanitized") + require.Equal(t, 0, reasoningItemCount(t, nextAccount), "later Bedrock account must remain sanitized") + require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated") +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index 947d3e8ff..daffc984d 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -586,7 +586,7 @@ func (r *ResponsesClientToolStreamRestorer) restoreNamespaceEvent(event Response event.Item.Name, event.Item.Namespace = name.Name, name.Namespace } } - if event.Type == "response.function_call_arguments.done" { + if event.Type == "response.function_call_arguments.delta" || event.Type == "response.function_call_arguments.done" { if name, ok := r.adapter.NamespaceTools[event.Name]; ok { event.Name = name.Name } diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 61dc0d648..91ee0890a 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -146,7 +146,13 @@ func TestResponsesClientToolStreamRestorer_RestoresNamespaceLifecycle(t *testing require.Equal(t, "open", gjson.GetBytes(added[0], "item.name").String()) require.Equal(t, "browser", gjson.GetBytes(added[0], "item.namespace").String()) - done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`)) + delta, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.delta","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","delta":"{\"url\":"}`)) + require.NoError(t, err) + require.True(t, changed) + require.Len(t, delta, 1) + require.Equal(t, "open", gjson.GetBytes(delta[0], "name").String()) + + done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":6,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`)) require.NoError(t, err) require.True(t, changed) require.Len(t, done, 1) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 46f57d0ca..7a28b44ab 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -152,11 +152,7 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess case item.Type == "function_call_output": // function_call_output → user message with tool_result block - outputContent := item.Output - if outputContent == "" { - outputContent = "(empty)" - } - contentJSON, _ := json.Marshal(outputContent) + contentJSON := responsesFunctionOutputToAnthropicContent(item) block := AnthropicContentBlock{ Type: "tool_result", ToolUseID: fromResponsesCallIDToAnthropic(item.CallID), @@ -216,6 +212,45 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess return system, messages, nil } +func responsesFunctionOutputToAnthropicContent(item ResponsesInputItem) json.RawMessage { + if len(item.outputRaw) == 0 { + output := item.Output + if output == "" { + output = "(empty)" + } + content, _ := json.Marshal(output) + return content + } + + var parts []ResponsesContentPart + if err := json.Unmarshal(item.outputRaw, &parts); err == nil { + blocks := make([]AnthropicContentBlock, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "input_text", "output_text", "text": + if part.Text != "" { + blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: part.Text}) + } + case "input_image": + if source := dataURIToAnthropicImageSource(part.ImageURL); source != nil { + blocks = append(blocks, AnthropicContentBlock{Type: "image", Source: source}) + } + } + } + if len(blocks) > 0 { + content, _ := json.Marshal(blocks) + return content + } + if len(parts) == 0 { + content, _ := json.Marshal("(empty)") + return content + } + } + + content, _ := json.Marshal(item.Output) + return content +} + // normalizeAnthropicToolPairing rebuilds the message sequence so it satisfies // Anthropic's tool_use/tool_result invariants, which the naive item-by-item // conversion violates whenever the Responses history interleaves anything diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index 1a51b5478..42dd2d0cc 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -163,3 +163,28 @@ func TestAnthropicPairing_SingleCall(t *testing.T) { require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A")) require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A")) } + +func TestResponsesToAnthropic_FunctionOutputContentArray(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_A","output":[ + {"type":"input_text","text":"image loaded"}, + {"type":"input_image","image_url":"data:image/png;base64,YQ=="} + ]} + ]`) + + require.Len(t, msgs, 2) + resultBlocks := parseContentBlocks(msgs[1].Content) + require.Len(t, resultBlocks, 1) + require.Equal(t, "tool_result", resultBlocks[0].Type) + + var content []AnthropicContentBlock + require.NoError(t, json.Unmarshal(resultBlocks[0].Content, &content)) + require.Len(t, content, 2) + require.Equal(t, "text", content[0].Type) + require.Equal(t, "image loaded", content[0].Text) + require.Equal(t, "image", content[1].Type) + require.NotNil(t, content[1].Source) + require.Equal(t, "image/png", content[1].Source.MediaType) + require.Equal(t, "YQ==", content[1].Source.Data) +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go index 1b732a10e..54873b547 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go @@ -122,6 +122,9 @@ func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T) assert.Equal(t, "web_search_20250305", tools[2].Type) assert.Equal(t, "web_search", tools[2].Name) assert.Empty(t, tools[2].InputSchema) + serverToolWire, err := json.Marshal(tools[2]) + require.NoError(t, err) + assert.NotContains(t, string(serverToolWire), `"input_schema"`) } func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 6031eb41d..c4cde405e 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -4,7 +4,10 @@ // formats can be served through a unified gateway. package apicompat -import "encoding/json" +import ( + "bytes" + "encoding/json" +) // --------------------------------------------------------------------------- // Anthropic Messages API types @@ -112,7 +115,7 @@ type AnthropicTool struct { Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools Name string `json:"name"` Description string `json:"description,omitempty"` - InputSchema json.RawMessage `json:"input_schema"` // JSON Schema object + InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema object CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` } @@ -260,7 +263,34 @@ type ResponsesInputItem struct { ID string `json:"id,omitempty"` // type=function_call_output - Output string `json:"output,omitempty"` + Output string `json:"output,omitempty"` + outputRaw json.RawMessage +} + +func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { + type alias ResponsesInputItem + var wire struct { + *alias + Output json.RawMessage `json:"output"` + } + + *i = ResponsesInputItem{} + wire.alias = (*alias)(i) + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + output := bytes.TrimSpace(wire.Output) + if len(output) == 0 || bytes.Equal(output, []byte("null")) { + return nil + } + if err := json.Unmarshal(output, &i.Output); err == nil { + return nil + } + + i.outputRaw = append(i.outputRaw[:0], output...) + i.Output = string(output) + return nil } // ResponsesContentPart is a typed content part in a Responses message. diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 310f02788..0e8a05d36 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -66,7 +66,7 @@ const DefaultCacheControlTTL = "5m" // CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。 // 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。 // 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。 -const CLICurrentVersion = "2.1.161" +const CLICurrentVersion = "2.1.220" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 29af4d2d5..da9e748b1 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -270,6 +270,7 @@ type UsageLogFilters struct { APIKeyID int64 AccountID int64 GroupID int64 + RequestID string Model string // ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics. ModelFilterSource string diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 6ab4446ba..212523a4f 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -3358,6 +3358,12 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va // ListDueUpstreamBillingProbeAccounts bounds result hydration and network work // to limit. PostgreSQL must still filter and order all enabled candidates; // MATERIALIZED avoids repeating the defensive timestamp parse expression. +// Go writes next_probe_at via RFC3339Nano (up to 9 fractional digits) while +// jsonpath datetime() parses at most microseconds, so fractions beyond 6 +// digits are trimmed first — mirroring ListDueOllamaCloudUsageAccounts. +// Without this, every nanosecond timestamp is treated as malformed and the +// fail-open ordering pins the cycle to the lowest account IDs, starving the +// rest of the pool. func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { if limit <= 0 { return []service.Account{}, nil @@ -3387,7 +3393,11 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont jsonb_path_query_first_tz( jsonb_build_object( 'value', - replace(regexp_replace(next_probe_at, 'Z$', '+00:00'), 'T', ' ') + replace(regexp_replace(regexp_replace( + next_probe_at, + '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', + '\1\2' + ), 'Z$', '+00:00'), 'T', ' ') ), '$.value.datetime()', '{}'::jsonb, diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go index 64d29553f..104771af0 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go @@ -49,3 +49,107 @@ func TestListDueUpstreamBillingProbeAccountsHandlesInvalidCalendarDate(t *testin require.Equal(t, invalidID, accounts[0].ID) require.Equal(t, dueID, accounts[1].ID) } + +func insertUpstreamBillingProbeAccount(ctx context.Context, t *testing.T, tx sqlQueryer, name, nextProbeAt string) int64 { + t.Helper() + var id int64 + extra := fmt.Sprintf(`{ + "upstream_billing_probe_enabled": true, + "upstream_billing_probe": {"status": "ok", "next_probe_at": %q} + }`, nextProbeAt) + err := scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ($1, 'openai', $2, 'active', $3::jsonb) + RETURNING id + `, []any{name, service.AccountTypeAPIKey, extra}, &id) + require.NoError(t, err) + return id +} + +// rfc3339WithFraction renders t in UTC with an explicit fractional-second +// suffix, e.g. "2026-07-25T17:29:00.123456789Z". The probes' Go writer uses +// RFC3339Nano, so stored fractions carry up to 9 digits. +func rfc3339WithFraction(t time.Time, fraction string) string { + return fmt.Sprintf("%s.%sZ", t.UTC().Format("2006-01-02T15:04:05"), fraction) +} + +// Regression for the scheduled-probe starvation bug: Go persists next_probe_at +// via RFC3339Nano (7-9 fractional digits), which jsonpath datetime() cannot +// parse. Before the trim fix every such row was treated as malformed and +// fail-open "due", so the cycle always returned the lowest account IDs and +// higher IDs were never probed. +func TestListDueUpstreamBillingProbeAccountsParsesNanosecondTimestamps(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + // 22 low-ID accounts that are NOT due yet, stored with 9 fractional digits + // exactly as the legacy writer produced them (no migration). + notDue := now.Add(time.Hour) + for i := 0; i < 22; i++ { + insertUpstreamBillingProbeAccount(ctx, t, tx, + fmt.Sprintf("probe-nano-not-due-%02d", i), + rfc3339WithFraction(notDue.Add(time.Duration(i)*time.Second), "123456789")) + } + // 3 high-ID accounts that ARE due, with 9/8/7 fractional digits. Their + // parsed order must follow due time, not insertion/ID order. + dueThird := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-9digits", rfc3339WithFraction(now.Add(-time.Minute), "123456789")) + dueFirst := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-8digits", rfc3339WithFraction(now.Add(-3*time.Minute), "12345678")) + dueSecond := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-7digits", rfc3339WithFraction(now.Add(-2*time.Minute), "1234567")) + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 3) + require.Equal(t, dueFirst, accounts[0].ID) + require.Equal(t, dueSecond, accounts[1].ID) + require.Equal(t, dueThird, accounts[2].ID) +} + +// With more due accounts than the cycle limit, selection must follow the +// parsed due time so accounts beyond the first IDs still get their +// turn; before the fix the same lowest IDs monopolized every cycle. +func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + // 25 due accounts; the LOWEST IDs carry the LATEST due times, so a + // correct query must pick the 20 highest-ID rows here. + ids := make([]int64, 0, 25) + for i := 0; i < 25; i++ { + due := now.Add(-time.Duration(i+1) * time.Minute) + ids = append(ids, insertUpstreamBillingProbeAccount(ctx, t, tx, + fmt.Sprintf("probe-nano-order-%02d", i), + rfc3339WithFraction(due, "123456789"))) + } + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 20) + got := make([]int64, 0, len(accounts)) + for _, account := range accounts { + got = append(got, account.ID) + } + // Earliest due first == highest index first; the five most recently due + // (lowest index / lowest IDs) fall outside the limit this cycle. + want := make([]int64, 0, 20) + for i := 24; i >= 5; i-- { + want = append(want, ids[i]) + } + require.Equal(t, want, got) +} diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go index 350b95d4a..aa1fcb430 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go @@ -32,6 +32,7 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test require.Contains(t, normalized, "type = 'apikey'") require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`) require.Contains(t, normalized, "jsonb_path_query_first_tz") + require.Contains(t, normalized, `'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$'`) require.Contains(t, normalized, "parsed AS MATERIALIZED") require.Contains(t, normalized, "parsed_next_probe_at::timestamptz <= $1") require.Contains(t, normalized, "LIMIT $2") diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index 1fb99b0d6..69f50bbad 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -40,8 +40,8 @@ func TestResolveModelDimensionExpression(t *testing.T) { want string }{ {usagestats.ModelSourceRequested, "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, - {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model))"}, - {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model)))"}, + {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, + {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), model))"}, {"", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, {"invalid", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, } diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 88b7eeaf3..084b79ad9 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -116,6 +116,10 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) args = append(args, filters.GroupID) } + if requestID := strings.TrimSpace(filters.RequestID); requestID != "" { + conditions = append(conditions, fmt.Sprintf("request_id = $%d", len(args)+1)) + args = append(args, requestID) + } conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) if filters.BillingType != nil { diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index aff472b0c..9c59b38e5 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -374,6 +374,23 @@ func TestUsageLogRepositoryListWithFiltersRequestTypePriority(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestUsageLogRepositoryListWithFiltersRequestID(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + + filters := usagestats.UsageLogFilters{RequestID: " req-0123 "} + + mock.ExpectQuery("SELECT .* FROM usage_logs WHERE request_id = \\$1 ORDER BY id DESC LIMIT \\$2 OFFSET \\$3"). + WithArgs("req-0123", 21, 0). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + logs, page, err := repo.ListWithFilters(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 20}, filters) + require.NoError(t, err) + require.Empty(t, logs) + require.NotNil(t, page) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestUsageLogRepositoryListWithFiltersRequestedModelSource(t *testing.T) { db, mock := newSQLMock(t) repo := &usageLogRepository{sql: db} diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index f2e0045ff..ae431c706 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -750,11 +750,12 @@ func resolveModelDimensionExpressionWithAlias(modelType, alias string) string { return alias + "." + name } requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model")) + upstreamExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), column("model")) switch usagestats.NormalizeModelSource(modelType) { case usagestats.ModelSourceUpstream: - return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr) + return upstreamExpr case usagestats.ModelSourceMapping: - return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr) + return fmt.Sprintf("(%s || ' -> ' || %s)", requestedExpr, upstreamExpr) default: return requestedExpr } diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index 3ad690d4d..e7238b517 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -44,7 +44,7 @@ type ConfigStore interface { // It must stay false when blocking is not intended, even if config is // untrusted—otherwise default-off deployments fail closed for all traffic. BlockingActivationDegraded() bool - Public() PublicConfig + Public() (PublicConfig, error) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) Encrypt(value string) (string, error) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 7bc70f4c0..70d17da94 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -194,15 +194,15 @@ func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() { } } -func (m *ConfigManager) Public() PublicConfig { +func (m *ConfigManager) Public() (PublicConfig, error) { if m == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } snapshot := m.snapshot.Load() if snapshot == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } - return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled) + return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil } func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 22ea50a35..b71fca1d5 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -56,6 +56,57 @@ func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) { require.LessOrEqual(t, len([]rune(message)), 160) } +func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) { + t.Run("absent persisted setting is legitimate default", func(t *testing.T) { + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: "", + SettingKeyRiskControl: "false", + }}, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(1), public.ConfigVersion) + require.False(t, public.Enabled) + }) + + t.Run("persisted config activation failure is unavailable", func(t *testing.T) { + const canary = "persisted-token-canary" + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`, + SettingKeyRiskControl: "true", + }}, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.Error(t, err) + require.Empty(t, public) + require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err)) + require.NotContains(t, err.Error(), canary) + }) + + t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) { + storage := DefaultStorageConfig() + storage.ConfigVersion = 4 + storage.ChangeSummary = "trusted snapshot" + raw, err := json.Marshal(storage) + require.NoError(t, err) + repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: string(raw), + SettingKeyRiskControl: "false", + }}} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + repository.loadErr = errors.New("settings unavailable") + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(4), public.ConfigVersion) + require.Equal(t, "trusted snapshot", public.ChangeSummary) + }) +} + func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) { manager := &ConfigManager{encryptor: prefixEncryptor{}} current := DefaultStorageConfig() @@ -137,6 +188,18 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string return nil, errors.New("settings unavailable") } +type switchableSettingRepository struct { + staticSettingRepository + loadErr error +} + +func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if r.loadErr != nil { + return nil, r.loadErr + } + return r.staticSettingRepository.GetMultiple(ctx, keys) +} + func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) { // Settings unavailable and no prior blocking intent: stay ModeOff so the // gateway remains usable and admins can still disable/configure Prompt Audit. diff --git a/backend/internal/securityaudit/prompt_guard_test.go b/backend/internal/securityaudit/prompt_guard_test.go index 76e9dea05..2346857ea 100644 --- a/backend/internal/securityaudit/prompt_guard_test.go +++ b/backend/internal/securityaudit/prompt_guard_test.go @@ -222,7 +222,12 @@ func TestGuardEvaluatorFlagSharedDeadlineFailClosedAndContextCancel(t *testing.T elapsed := time.Since(started) require.Error(t, err) require.Equal(t, 2, calls) - require.Less(t, elapsed, 180*time.Millisecond) + // The bound only has to prove the failover shared the first endpoint's + // 70ms deadline instead of taking the second endpoint's own 500ms one. + // An unshared deadline lands at ~535ms, so 350ms still fails loudly + // while leaving room for scheduler delay on a busy CI machine. A + // tighter bound made this test flaky, not stricter. + require.Less(t, elapsed, 350*time.Millisecond) require.GreaterOrEqual(t, elapsed, 50*time.Millisecond) require.Equal(t, int64(1), metrics.Snapshot().Failovers) require.Equal(t, int64(1), metrics.Snapshot().Timeouts) diff --git a/backend/internal/securityaudit/prompt_handler.go b/backend/internal/securityaudit/prompt_handler.go index b2d5a51a6..24284aa54 100644 --- a/backend/internal/securityaudit/prompt_handler.go +++ b/backend/internal/securityaudit/prompt_handler.go @@ -13,7 +13,7 @@ import ( ) type PromptAdminService interface { - GetConfig() PublicConfig + GetConfig() (PublicConfig, error) SaveConfig(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) Probe(context.Context, ProbeRequest) ProbeResult Runtime(context.Context) RuntimeSnapshot @@ -31,7 +31,14 @@ func NewPromptAdminHandler(service PromptAdminService) *PromptAdminHandler { return &PromptAdminHandler{service: service} } -func (h *PromptAdminHandler) GetConfig(c *gin.Context) { response.Success(c, h.service.GetConfig()) } +func (h *PromptAdminHandler) GetConfig(c *gin.Context) { + config, err := h.service.GetConfig() + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, config) +} func (h *PromptAdminHandler) UpdateConfig(c *gin.Context) { var request UpdateConfigRequest diff --git a/backend/internal/securityaudit/prompt_handler_test.go b/backend/internal/securityaudit/prompt_handler_test.go index 51a4305f3..8561ad93e 100644 --- a/backend/internal/securityaudit/prompt_handler_test.go +++ b/backend/internal/securityaudit/prompt_handler_test.go @@ -29,7 +29,9 @@ type fakePromptAdminService struct { deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error) } -func (s *fakePromptAdminService) GetConfig() PublicConfig { return s.config } +func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) { + return s.config, nil +} func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { if s.save == nil { return PublicConfig{}, errors.New("unexpected SaveConfig call") @@ -156,6 +158,21 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test }) } +func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) { + const canary = "persisted-config-secret-canary" + repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + service := &PromptService{config: manager} + + response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil) + require.Equal(t, http.StatusServiceUnavailable, response.Code) + require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable) + require.NotContains(t, response.Body.String(), canary) + require.NotContains(t, response.Body.String(), `"config_version"`) + require.NotContains(t, response.Body.String(), `"token"`) +} + func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) { const canary = "probe-token-canary" for _, tc := range []struct { diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 71d0fadbe..2d9d6605e 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -166,7 +166,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis return s.evaluator.Evaluate(ctx, cfg, snapshot) } -func (s *PromptService) GetConfig() PublicConfig { return s.config.Public() } +func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() } func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { return s.config.Save(ctx, req, actorID) diff --git a/backend/internal/securityaudit/prompt_types.go b/backend/internal/securityaudit/prompt_types.go index 34611444e..7b25c33bd 100644 --- a/backend/internal/securityaudit/prompt_types.go +++ b/backend/internal/securityaudit/prompt_types.go @@ -12,11 +12,12 @@ const ( ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate" PayloadKeyPrefix = "sub2api:prompt_audit:payload:" - ErrorCodeBlocked = "prompt_guard_blocked" - ErrorCodeUnavailable = "prompt_guard_unavailable" - ErrorCodeInvalidResponse = "prompt_guard_invalid_response" - ErrorCodeConfigConflict = "prompt_audit_config_conflict" - ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" + ErrorCodeBlocked = "prompt_guard_blocked" + ErrorCodeUnavailable = "prompt_guard_unavailable" + ErrorCodeInvalidResponse = "prompt_guard_invalid_response" + ErrorCodeConfigConflict = "prompt_audit_config_conflict" + ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable" + ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" DefaultGuardModel = "sileader/qwen3guard:0.6b" ) diff --git a/backend/internal/securityaudit/prompt_worker_test.go b/backend/internal/securityaudit/prompt_worker_test.go index 5c39be8e4..a91569c2c 100644 --- a/backend/internal/securityaudit/prompt_worker_test.go +++ b/backend/internal/securityaudit/prompt_worker_test.go @@ -48,7 +48,7 @@ func (s *fakeConfigStore) EffectiveMode() Mode { return s.cfg.EffectiveMode() } func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false } -func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} } +func (s *fakeConfigStore) Public() (PublicConfig, error) { return PublicConfig{}, nil } func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) { return PublicConfig{}, nil } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 57ace01c2..a934a5621 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) @@ -698,6 +699,25 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { + channel := &Channel{ + ID: 1, + Status: StatusActive, + ApplyPricingToAccountStats: false, + } + cs := newTestChannelServiceForStats(t, channel, 10, "antigravity") + bs := NewBillingService(&config.Config{}, nil) + + result := resolveAccountStatsCost( + context.Background(), + cs, bs, + 1, 10, "gemini-3.6-flash-low", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, + ) + require.NotNil(t, result) + require.InDelta(t, 9.15, *result, 1e-12) +} + func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 29f611450..3200174b2 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -794,6 +794,16 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account * if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusPaymentRequired && s.accountRepo != nil { + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + _ = s.accountRepo.SetTempUnschedulable( + stateCtx, + account.ID, + time.Now().Add(30*time.Minute), + "grok payment required", + ) + } return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 98e63899b..c0b4faef5 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1588,7 +1588,8 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) { require.Equal(t, "router/gpt-", route.PublicModel) require.Equal(t, CompositeRouteMatchPrefix, route.MatchType) require.Equal(t, PlatformOpenAI, route.TargetPlatform) - require.Equal(t, "router/gpt-", route.UpstreamModel) + // prefix 路由留空 upstream_model 不再回填 public_model:留空表示透传原始请求模型。 + require.Equal(t, "", route.UpstreamModel) require.Equal(t, CompositeRouteEndpointResponses, route.Endpoint) require.Equal(t, 100, route.Priority) require.True(t, route.Enabled) @@ -1596,6 +1597,29 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) { require.Equal(t, route, routeRepo.created) } +// TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel 锁定 +// 保守行为:exact 路由留空 upstream_model 仍回填 public_model(持久化/展示契约不变)。 +func TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel(t *testing.T) { + groupRepo := &groupRepoStubForAdmin{ + getByID: &Group{ID: 7, Platform: PlatformComposite}, + } + routeRepo := &compositeRouteRepoStubForAdmin{nextID: 99} + svc := &adminServiceImpl{groupRepo: groupRepo, compositeRouteRepo: routeRepo} + + route, err := svc.CreateCompositeRoute(context.Background(), 7, CompositeRouteInput{ + PublicModel: "openrouter/gpt-5", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + Endpoint: CompositeRouteEndpointResponses, + Enabled: true, + }) + + require.NoError(t, err) + require.NotNil(t, route) + require.Equal(t, CompositeRouteMatchExact, route.MatchType) + require.Equal(t, "openrouter/gpt-5", route.UpstreamModel) +} + func TestAdminService_UpdateAndDeleteCompositeRouteRequireRouteOwnership(t *testing.T) { groupRepo := &groupRepoStubForAdmin{ getByID: &Group{ID: 7, Platform: PlatformComposite}, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 98eb9c3ec..748eaf009 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -272,6 +272,17 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + // Gemini 3.6 Flash (Google AI pricing: $1.50 input / $7.50 output / + // $0.15 cached input per MTok). Antigravity's -high/-low/-medium/-tiered + // aliases are matched below so unavailable remote pricing never records + // token-bearing requests at $0. + s.fallbackPrices["gemini-3.6-flash"] = &ModelPricing{ + InputPricePerToken: 1.5e-6, + OutputPricePerToken: 7.5e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } + // OpenAI GPT-5.4(业务指定价格) s.fallbackPrices["gpt-5.4"] = &ModelPricing{ InputPricePerToken: 2.5e-6, // $2.5 per MTok @@ -630,6 +641,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") { return s.fallbackPrices["gemini-3.1-pro"] } + if strings.Contains(modelLower, "gemini-3.6-flash") || strings.Contains(modelLower, "gemini-3-6-flash") { + return s.fallbackPrices["gemini-3.6-flash"] + } // DeepSeek V4 系列:仅匹配已知 V4 Pro/Flash 与官方兼容别名 // (deepseek-chat / deepseek-reasoner → V4 Flash),未知 deepseek-* 型号不回退,避免误计价。 diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index 2bae5c62d..a008a3e9f 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -118,7 +118,12 @@ func normalizeCompositeRouteInput(input CompositeRouteInput) CompositeRouteInput input.TargetPlatform = strings.TrimSpace(input.TargetPlatform) input.UpstreamModel = strings.TrimSpace(input.UpstreamModel) input.Endpoint = normalizeCompositeRouteEndpoint(input.Endpoint) - if input.UpstreamModel == "" { + // 仅对 exact 路由把空 upstream_model 回填成 public_model:exact 命中时请求模型 + // 恒等于 public_model,回填只影响持久化/后台展示,保留原有契约不变。 + // prefix 路由留空则不回填——Resolve 会回退到具体请求模型,从而透传原始模型 + // (否则 public=deepseek-v4 的前缀路由会把 deepseek-v4-flash / deepseek-v4-pro + // 都塌缩成固定的 deepseek-v4)。显式填写 upstream_model 时任何模式都原样固定转发。 + if input.UpstreamModel == "" && input.MatchType == CompositeRouteMatchExact { input.UpstreamModel = input.PublicModel } input.Notes = strings.TrimSpace(input.Notes) diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index ed6f6f085..35028934d 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -107,6 +107,63 @@ func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T require.Equal(t, int64(2), decision.Route.ID) } +// TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel 验证: +// 前缀匹配路由留空 upstream_model 时,转发的是具体请求模型(各自原样),而不是 +// 塌缩成 public_model。这是「留空 = 透传原始模型」语义的核心场景。 +func TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{ + { + ID: 1, + GroupID: 7, + PublicModel: "deepseek-v4", + MatchType: CompositeRouteMatchPrefix, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "", // 留空 = 透传 + Endpoint: CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + + for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4"} { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions) + require.NoError(t, err) + require.True(t, decision.Matched, "model %q should match prefix route", model) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, PlatformOpenAI, decision.TargetPlatform) + require.Equal(t, model, decision.UpstreamModel, "model %q should pass through verbatim", model) + } +} + +// TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed 验证:前缀匹配路由显式 +// 填写 upstream_model 时,所有命中请求仍转发同一个固定上游模型(行为不变)。 +func TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{ + { + ID: 1, + GroupID: 7, + PublicModel: "deepseek-v4", + MatchType: CompositeRouteMatchPrefix, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "deepseek-chat", + Endpoint: CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + + for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, "deepseek-chat", decision.UpstreamModel) + } +} + func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 9fbbc674d..9450ddbb1 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -1208,3 +1208,28 @@ func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Co } return s.settingService.GetClaudeOAuthSystemPromptInjectionSettings(ctx) } + +// systemHasBillingAttributionBlock 检查请求体的 system 字段中是否包含真实 Claude Code +// 客户端注入的 billing attribution block。该 block 格式稳定(见 gateway_billing_block.go), +// 仅由真实 Claude Code CLI 生成;第三方客户端(opencode 等)不会生成此 block。 +// +// 用于识别被上游 API 网关代理的真实 Claude Code 流量:此类请求的 User-Agent 被网关替换 +// 为 Go-http-client,但 body 保留了完整的客户端特征。如果不识别这类请求而走 mimicry +// 重写 system,会破坏 Anthropic prompt cache 的前缀一致性,导致 messages 级缓存永不命中。 +func systemHasBillingAttributionBlock(body []byte) bool { + system := gjson.GetBytes(body, "system") + if !system.IsArray() { + return false + } + found := false + system.ForEach(func(_, item gjson.Result) bool { + text := item.Get("text").String() + if strings.HasPrefix(text, claudeCodeBillingHeaderPrefix) && + strings.Contains(text, claudeCodeEntrypointMarker) { + found = true + return false + } + return true + }) + return found +} diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 3005d93fa..5a78a1981 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -170,6 +170,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A clientUserAgent = c.GetHeader("User-Agent") } isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID) + + // 补充判定:上游 API 网关(如 new-api)转发真实 Claude Code 流量时, + // UA 会变成 Go-http-client 但 body 保留了完整的 Claude Code 特征 + // (billing attribution block + metadata.user_id)。此时如果仍走 mimicry + // 重写 system prompt,会破坏 Anthropic prompt cache 的前缀匹配—— + // 导致 messages 级缓存永远 miss、cache_creation 每轮全量重写。 + // 通过检查 body 中的 billing attribution block 来识别被代理的真实 CC 流量。 + if !isClaudeCode && parsed.MetadataUserID != "" { + isClaudeCode = systemHasBillingAttributionBlock(body) + } + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index e5f0ddc3c..2aa5255c2 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,15 +37,21 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() - // 1. Parse Responses request + // 1. Lower Codex client-side tools to function tools understood by Anthropic. + adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body) + if err != nil { + return nil, fmt.Errorf("adapt responses client tools: %w", err) + } + + // 2. Parse Responses request var responsesReq apicompat.ResponsesRequest - if err := json.Unmarshal(body, &responsesReq); err != nil { + if err := json.Unmarshal(adaptedBody, &responsesReq); err != nil { return nil, fmt.Errorf("parse responses request: %w", err) } originalModel := responsesReq.Model clientStream := responsesReq.Stream - // 2. Convert Responses → Anthropic + // 3. Convert Responses → Anthropic anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) if err != nil { return nil, fmt.Errorf("convert responses to anthropic: %w", err) @@ -182,14 +188,71 @@ func (s *GatewayService) ForwardAsResponses( var result *ForwardResult var handleErr error if clientStream { - result, handleErr = s.handleResponsesStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime) + result, handleErr = s.handleResponsesStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime, clientToolMapping) } else { - result, handleErr = s.handleResponsesBufferedStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime) + result, handleErr = s.handleResponsesBufferedStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime, clientToolMapping) } return result, handleErr } +func adaptResponsesClientToolsForAnthropic(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var requestBody map[string]any + if err := decoder.Decode(&requestBody); err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + additionalToolsChanged, err := liftResponsesAdditionalTools(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + + mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + changed = changed || additionalToolsChanged + if !changed { + return body, mapping, nil + } + rebuilt, err := json.Marshal(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + return rebuilt, mapping, nil +} + +func liftResponsesAdditionalTools(requestBody map[string]any) (bool, error) { + input, ok := requestBody["input"].([]any) + if !ok { + return false, nil + } + + tools, _ := requestBody["tools"].([]any) + kept := make([]any, 0, len(input)) + changed := false + for _, raw := range input { + item, ok := raw.(map[string]any) + if !ok || strings.TrimSpace(fmt.Sprint(item["type"])) != "additional_tools" { + kept = append(kept, raw) + continue + } + additional, ok := item["tools"].([]any) + if !ok { + return false, fmt.Errorf("additional_tools.tools must be an array") + } + tools = append(tools, additional...) + changed = true + } + if !changed { + return false, nil + } + requestBody["tools"] = tools + requestBody["input"] = kept + return true, nil +} + // ExtractResponsesReasoningEffortFromBody reads Responses API reasoning.effort // and normalizes it for usage logging. func ExtractResponsesReasoningEffortFromBody(body []byte) *string { @@ -243,6 +306,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( mappedModel string, reasoningEffort *string, startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -358,6 +422,10 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") if respBytes, err := json.Marshal(responsesResp); err == nil { respBytes = reverseToolNamesIfPresent(c, respBytes) + respBytes, _, err = apicompat.RestoreResponsesClientToolPayload(respBytes, clientToolMapping) + if err != nil { + return nil, fmt.Errorf("restore responses client tools: %w", err) + } c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) } else { c.JSON(http.StatusOK, responsesResp) @@ -383,6 +451,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( mappedModel string, reasoningEffort *string, startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -397,6 +466,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage var firstTokenMs *int firstChunk := true @@ -441,7 +511,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( // Convert to Responses events events := apicompat.AnthropicEventToResponsesEvents(event, state) for _, evt := range events { - sse, err := apicompat.ResponsesEventToSSE(evt) + payload, err := json.Marshal(evt) if err != nil { logger.L().Warn("forward_as_responses stream: failed to marshal event", zap.Error(err), @@ -449,12 +519,23 @@ func (s *GatewayService) handleResponsesStreamingResponse( ) continue } - out := string(reverseToolNamesIfPresent(c, []byte(sse))) - if _, err := fmt.Fprint(c.Writer, out); err != nil { - logger.L().Info("forward_as_responses stream: client disconnected", + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + logger.L().Warn("forward_as_responses stream: failed to restore client tools", + zap.Error(err), zap.String("request_id", requestID), ) - return true // client disconnected + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + logger.L().Info("forward_as_responses stream: client disconnected", + zap.String("request_id", requestID), + ) + return true // client disconnected + } } } if len(events) > 0 { diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index baaf8b954..6784e7c7a 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -3,6 +3,7 @@ package service import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -10,10 +11,129 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +func TestAdaptResponsesClientToolsForAnthropic_FlattensNamespace(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"claude-fable-5", + "input":[{"type":"function_call","call_id":"call_1","namespace":"codex_app","name":"read_thread","arguments":"{}"}], + "tools":[{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"read_thread","description":"Read a task","parameters":{"type":"object","properties":{}}}]}] + }`) + + adapted, mapping, err := adaptResponsesClientToolsForAnthropic(body) + require.NoError(t, err) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "codex_app", Name: "read_thread"}, mapping.NamespaceTools["codex_app__read_thread"]) + + var request map[string]any + require.NoError(t, json.Unmarshal(adapted, &request)) + tools := request["tools"].([]any) + require.Len(t, tools, 1) + tool := tools[0].(map[string]any) + require.Equal(t, "function", tool["type"]) + require.Equal(t, "codex_app__read_thread", tool["name"]) + + input := request["input"].([]any) + call := input[0].(map[string]any) + require.Equal(t, "codex_app__read_thread", call["name"]) + require.NotContains(t, call, "namespace") +} + +func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T) { + body := []byte(`{ + "model":"claude-fable-5", + "input":[ + {"type":"additional_tools","tools":[ + {"type":"custom","name":"exec","description":"Run a command"}, + {"type":"namespace","name":"codex_app","tools":[ + {"type":"function","name":"read_thread","parameters":{"type":"object"}} + ]} + ]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"}]} + ] + }`) + + adapted, mapping, err := adaptResponsesClientToolsForAnthropic(body) + require.NoError(t, err) + require.True(t, mapping.CustomTools["exec"]) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "codex_app", Name: "read_thread"}, mapping.NamespaceTools["codex_app__read_thread"]) + + var request map[string]any + require.NoError(t, json.Unmarshal(adapted, &request)) + tools := request["tools"].([]any) + require.Len(t, tools, 2) + require.Equal(t, "function", tools[0].(map[string]any)["type"]) + require.Equal(t, "codex_app__read_thread", tools[1].(map[string]any)["name"]) + + input := request["input"].([]any) + require.Len(t, input, 1) + require.Equal(t, "message", input[0].(map[string]any)["type"]) +} + +func namespaceToolAnthropicStream() string { + return strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_namespace","type":"message","role":"assistant","content":[],"model":"claude-fable-5","stop_reason":"","usage":{"input_tokens":10}}}`, + ``, + `event: content_block_start`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_namespace","name":"codex_app__read_thread","input":{"thread_id":"123"}}}`, + ``, + `event: content_block_stop`, + `data: {"type":"content_block_stop","index":0}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") +} + +func namespaceToolMapping() apicompat.ResponsesClientToolMapping { + return apicompat.ResponsesClientToolMapping{NamespaceTools: map[string]apicompat.ResponsesNamespaceName{ + "codex_app__read_thread": {Namespace: "codex_app", Name: "read_thread"}, + }} +} + +func TestHandleResponsesBufferedStreamingResponse_RestoresNamespaceTool(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))} + + svc := &GatewayService{} + _, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-fable-5", "claude-fable-5", nil, time.Now(), namespaceToolMapping()) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), `"type":"function_call"`) + require.Contains(t, rec.Body.String(), `"name":"read_thread"`) + require.Contains(t, rec.Body.String(), `"namespace":"codex_app"`) + require.NotContains(t, rec.Body.String(), `"name":"codex_app__read_thread"`) +} + +func TestHandleResponsesStreamingResponse_RestoresNamespaceTool(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))} + + svc := &GatewayService{} + _, err := svc.handleResponsesStreamingResponse(resp, c, "claude-fable-5", "claude-fable-5", nil, time.Now(), namespaceToolMapping()) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), `response.output_item.added`) + require.Contains(t, rec.Body.String(), `"name":"read_thread"`) + require.Contains(t, rec.Body.String(), `"namespace":"codex_app"`) + require.NotContains(t, rec.Body.String(), `"name":"codex_app__read_thread"`) +} + func TestExtractResponsesReasoningEffortFromBody(t *testing.T) { t.Parallel() @@ -51,7 +171,7 @@ func TestHandleResponsesBufferedStreamingResponse_PreservesMessageStartCacheUsag } svc := &GatewayService{} - result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 12, result.Usage.InputTokens) @@ -87,7 +207,7 @@ func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *tes } svc := &GatewayService{} - result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 20, result.Usage.InputTokens) @@ -198,7 +318,7 @@ func TestHandleResponsesBufferedStreamingResponse_CompactSSEFormat(t *testing.T) } svc := &GatewayService{} - result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 10, result.Usage.InputTokens) @@ -232,7 +352,7 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { } svc := &GatewayService{} - result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 15, result.Usage.InputTokens) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index e5bf49b8b..13fc32165 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -219,7 +219,15 @@ func TestGetUserGroupRateMultiplier_UsesCacheAndSingleflight(t *testing.T) { } close(start) - time.Sleep(20 * time.Millisecond) + // Wait for every caller to have recorded its cache miss before releasing the + // loader. A fixed sleep raced here: a goroutine that reached the cache after + // the singleflight load had already finished got a hit instead of a miss, and + // the miss assertion below saw 11 of 12. The miss counter is the observable + // that says "all callers are now inside the singleflight group". + require.Eventually(t, func() bool { + _, miss, _, _, _ := GatewayUserGroupRateCacheStats() + return miss == int64(concurrent) + }, 5*time.Second, time.Millisecond, "all callers must miss the cache before the loader is released") close(unblock) wg.Wait() diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 28963a430..81aff5827 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,64 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_channel_mapping_models", + Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 6}, + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-terra", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) +} + +func TestGatewayServiceRecordUsage_PreservesLoopedChannelAndAccountUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_looped_mapping_models", + Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 6}, + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageRepo.lastLog.UpstreamModel) +} + func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) { imagePrice2K := 0.19 groupID := int64(901) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index f445814d0..51b8031fc 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -986,7 +986,7 @@ func (s *GatewayService) buildRecordUsageLog( RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 5f868a5d6..f23c18008 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -231,7 +231,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( Kind: "failover", Message: upstreamMsg, }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: evBody, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } } return nil, s.writeGeminiChatCompletionsMappedError(c, account, resp.StatusCode, requestID, evBody) diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index deae912c9..e71bd141b 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -352,6 +352,68 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { } } +// --------------------------------------------------------------------------- +// TestPoolModeSkippedFailoverError — pool-mode accounts hitting +// ErrorPolicySkipped must failover (align with other platform forwards) +// instead of passing the upstream error through to the client. +// --------------------------------------------------------------------------- + +func TestPoolModeSkippedFailoverError(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &GeminiMessagesCompatService{} + + poolAccount := func(extra map[string]any) *Account { + creds := map[string]any{"pool_mode": true} + for k, v := range extra { + creds[k] = v + } + return &Account{ID: 300, Type: AccountTypeAPIKey, Platform: PlatformGemini, Credentials: creds} + } + + tests := []struct { + name string + account *Account + statusCode int + expectFailover bool + expectSameAccount bool + }{ + {"pool_500_failover_no_same_account_retry", poolAccount(nil), 500, true, false}, + {"pool_429_failover_with_same_account_retry", poolAccount(nil), 429, true, true}, + {"pool_custom_retry_codes_500", poolAccount(map[string]any{ + "pool_mode_retry_status_codes": []any{float64(500)}, + }), 500, true, true}, + {"pool_400_not_failover_worthy", poolAccount(nil), 400, false, false}, + {"non_pool_account_keeps_passthrough", &Account{ + ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, 500, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"error":{"code":"bad_response_status_code","message":"openai_error"}}`) + failoverErr := svc.poolModeSkippedFailoverError(c, tt.account, tt.statusCode, body, "req-1") + + if !tt.expectFailover { + require.Nil(t, failoverErr) + return + } + require.NotNil(t, failoverErr) + require.Equal(t, tt.statusCode, failoverErr.StatusCode) + require.Equal(t, body, failoverErr.ResponseBody) + require.Equal(t, tt.expectSameAccount, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.ShouldRetryNextAccount()) + }) + } +} + // --------------------------------------------------------------------------- // TestGeminiErrorPolicy_NilRateLimitService — verifies nil safety // --------------------------------------------------------------------------- diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 30f635643..09946ad1c 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -945,6 +945,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } + if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { + return nil, failoverErr + } return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { @@ -1452,6 +1455,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) switch policy { case ErrorPolicySkipped: + if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { + return nil, failoverErr + } respBody = unwrapIfNeeded(isOAuth, respBody) contentType := resp.Header.Get("Content-Type") if contentType == "" { @@ -1683,6 +1689,39 @@ func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCo } } +// poolModeSkippedFailoverError 池模式账号命中 ErrorPolicySkipped 时构造 failover 错误: +// 可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层按 pool_mode_retry_count +// 同账号重试后换号;返回 nil 表示不适用(非池模式或状态码不可 failover),由调用方透传。 +func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { + if !account.IsPoolMode() || !s.shouldFailoverGeminiUpstreamError(statusCode) { + return nil + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: statusCode, + UpstreamRequestID: upstreamRequestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return &UpstreamFailoverError{ + StatusCode: statusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolModeRetryableStatus(statusCode), + } +} + func sleepGeminiBackoff(attempt int) { delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index d315d7eb4..4b4a5aadb 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -36,6 +36,14 @@ type ollamaUsageTestRepo struct { disableAutoAttempts atomic.Int64 disableAutoCalls atomic.Int64 groupResolveCalls atomic.Int64 + getByIDCalls atomic.Int64 +} + +// GetByID counts loads so a test can wait for a caller to reach the point just +// before the singleflight group, instead of guessing with a sleep. +func (r *ollamaUsageTestRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + r.getByIDCalls.Add(1) + return r.upstreamBillingProbeAccountRepo.GetByID(ctx, id) } func (r *ollamaUsageTestRepo) ListOllamaCloudUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) { @@ -805,7 +813,19 @@ func TestOllamaCloudUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *t errs := make(chan error, 2) go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }() <-started + // The first caller is now parked in the stub, having loaded the account twice + // (once to build the group key, once inside the singleflight function). + loadsBeforeSecond := repo.getByIDCalls.Load() go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }() + // Only release the first caller once the second one has loaded its own + // account, which happens immediately before it joins the singleflight group. + // Releasing right after starting the goroutine raced: if the first refresh + // finished first, the second became a fresh singleflight execution, re-read + // the account, saw the LastAttemptAt just written, and failed with the 30s + // manual-refresh 429 instead of sharing the in-flight result. + require.Eventually(t, func() bool { + return repo.getByIDCalls.Load() > loadsBeforeSecond + }, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released") close(release) require.NoError(t, <-errs) require.NoError(t, <-errs) diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index d13cfa19f..09a0df401 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -2006,6 +2006,37 @@ func TestAccountTestServiceGrokAPIKeyAllowsConfiguredHTTPWhenGlobalPolicyDoes(t require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) } +func TestAccountTestServiceGrokOAuthPaymentRequiredTemporarilyUnschedulesAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := healthyGrokOAuthGatewayTestAccount(56, "access-token") + repo := &grokQuotaAccountRepo{} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":"personal-team-blocked:spending-limit"}`)), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/56/test", nil) + before := time.Now() + + err := svc.testGrokAccountConnection(c, account, "grok") + + require.Error(t, err) + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, account.ID, repo.lastTempUnschedID) + require.Equal(t, "grok payment required", repo.lastTempUnschedReason) + require.WithinDuration(t, before.Add(30*time.Minute), repo.lastTempUnschedUntil, time.Second) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.Contains(t, recorder.Body.String(), "Grok Responses API returned 402") +} + func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index d2eca05d7..f0dc78b74 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1335,6 +1335,71 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad require.Equal(t, 1, userRepo.deductCalls) } +func TestOpenAIGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_channel_mapping_models", + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-terra", + Usage: OpenAIUsage{ + InputTokens: 20, + OutputTokens: 10, + }, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) +} + +func TestOpenAIGatewayServiceRecordUsage_PreservesLoopedChannelAndAccountUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_looped_mapping_models", + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-sol", + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageRepo.lastLog.UpstreamModel) +} + func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 14520958f..fdd401911 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -156,6 +156,119 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep return inputItem, true, true } +// SanitizeOpenAICrossModeFailoverReasoning derives a failover attempt body from +// the canonical request body by dropping provider-specific encrypted reasoning +// input items in full (encrypted_content plus the coupled id/summary shape). +// +// This is the proactive counterpart to the reactive same-account +// invalid_encrypted_content recovery in Forward: when a failover switches from an +// OpenAI passthrough account (which forwards upstream-native encrypted reasoning, +// e.g. Kiro) to a non-passthrough account (e.g. Bedrock Mantle) that rejects the +// provider-specific reasoning IDs/shape, the whole reasoning item must go before +// the request reaches the new upstream. Unlike trimOpenAIEncryptedReasoningItems, +// which only strips the encrypted_content / null-content fields while preserving +// the reasoning item's id and summary, this drops the entire item. +// +// The input slice is treated as immutable and is never mutated; a distinct slice +// is returned only when changed is true. +func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, changed bool, err error) { + if len(body) == 0 { + return body, false, nil + } + if !gjson.GetBytes(body, "input").Exists() { + return body, false, nil + } + var decoded map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return body, false, fmt.Errorf("decode cross-mode failover body: %w", err) + } + if !dropOpenAIEncryptedReasoningInputItems(decoded) { + return body, false, nil + } + out, marshalErr := marshalOpenAIUpstreamJSON(decoded) + if marshalErr != nil { + return body, false, fmt.Errorf("serialize cross-mode failover body: %w", marshalErr) + } + return out, true, nil +} + +// dropOpenAIEncryptedReasoningInputItems removes reasoning input items that carry +// provider-specific encrypted_content in full — including their coupled id and +// summary — and reports whether anything changed. Contrast with +// trimOpenAIEncryptedReasoningItems, which only strips fields while keeping the +// reasoning item skeleton. +func dropOpenAIEncryptedReasoningInputItems(reqBody map[string]any) bool { + if len(reqBody) == 0 { + return false + } + inputValue, has := reqBody["input"] + if !has { + return false + } + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + changed := false + for _, item := range input { + if isOpenAIEncryptedReasoningInputItem(item) { + changed = true + continue + } + filtered = append(filtered, item) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case []map[string]any: + filtered := input[:0] + changed := false + for _, item := range input { + if isOpenAIEncryptedReasoningInputItem(item) { + changed = true + continue + } + filtered = append(filtered, item) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case map[string]any: + if isOpenAIEncryptedReasoningInputItem(input) { + delete(reqBody, "input") + return true + } + return false + default: + return false + } +} + +func isOpenAIEncryptedReasoningInputItem(item any) bool { + inputItem, ok := item.(map[string]any) + if !ok { + return false + } + if itemType, _ := inputItem["type"].(string); strings.TrimSpace(itemType) != "reasoning" { + return false + } + _, has := inputItem["encrypted_content"] + return has +} + func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { return isOpenAIResponsesCompactPath(c) } 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 62a885511..ad14f4d8d 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestTrimOpenAIEncryptedReasoningItems_ContentNull(t *testing.T) { @@ -98,6 +99,53 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) { assert.False(t, changed) } +func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[` + + `{"type":"message","role":"user","content":"hi"},` + + `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC","summary":[{"type":"summary_text","text":"t"}]},` + + `{"type":"message","role":"assistant","content":"yo"}` + + `]}`) + + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.True(t, changed) + // The whole reasoning item is gone — id and summary go with encrypted_content, + // unlike trimOpenAIEncryptedReasoningItems which keeps the skeleton. + require.NotContains(t, string(sanitized), "reasoning") + require.NotContains(t, string(sanitized), "rs_kiro_1") + require.NotContains(t, string(sanitized), "summary_text") + require.Equal(t, int64(2), gjson.GetBytes(sanitized, "input.#").Int()) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_NoEncryptedIsNoop(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"t"}]}]}`) + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.False(t, changed, "reasoning without encrypted_content must be preserved") + require.Equal(t, string(body), string(sanitized)) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_NoInputIsNoop(t *testing.T) { + body := []byte(`{"model":"gpt-5.1"}`) + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_PreservesLargeIntegers(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[` + + `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC"},` + + `{"type":"message","role":"user","content":"hi"}` + + `],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"id":{"const":9007199254740993}}}}}]}`) + + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.True(t, changed) + require.Contains(t, string(sanitized), `"const":9007199254740993`, + "sanitization must not round JSON integers through float64") +} + func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testing.T) { reqBody := map[string]any{ "input": []any{ diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 16429f740..124469833 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -256,7 +256,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index d22bec21d..9c0869fbe 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -207,8 +207,9 @@ func (e *OpenAIWSClientCloseError) Reason() string { // OpenAIWSIngressHooks 定义入站 WS 每个 turn 的生命周期回调。 type OpenAIWSIngressHooks struct { - // InitialRequestModel 是首帧渠道映射前的请求模型,只用于 usage metadata - // 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。 + // InitialRequestModel is the client-facing model from the first frame, + // before channel or account mapping. Ingress modes preserve it for usage + // attribution while MapRequestModel determines the upstream model. InitialRequestModel string // MaxReasoningEffort limits explicit reasoning effort values for this WS session. MaxReasoningEffort string @@ -216,7 +217,10 @@ type OpenAIWSIngressHooks struct { ReasoningEffortMappings []ReasoningEffortMapping BeforeTurn func(turn int) error BeforeRequest func(turn int, payload []byte, originalModel string) error - AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) + // MapRequestModel resolves the current turn's client model to the model + // that must be written into the upstream response.create frame. + MapRequestModel func(turn int, originalModel string) (string, error) + AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) } func (s *OpenAIGatewayService) getOpenAIWSConnPool() *openAIWSConnPool { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index c896ec794..3664ae8b2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -163,7 +163,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return rebuilt, nil } - parseClientPayload := func(raw []byte) (openAIWSClientPayload, error) { + parseClientPayload := func(turn int, raw []byte) (openAIWSClientPayload, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "empty websocket request payload", nil) @@ -288,7 +288,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = rebuilt } } - upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + requestModel := originalModel + if hooks != nil && hooks.MapRequestModel != nil { + mappedModel, mapErr := hooks.MapRequestModel(turn, originalModel) + if mapErr != nil { + return openAIWSClientPayload{}, mapErr + } + if mappedModel = strings.TrimSpace(mappedModel); mappedModel != "" { + requestModel = mappedModel + } + } + upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestModel)) if modelMissing || upstreamModel != originalModel { next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) if setErr != nil { @@ -412,7 +422,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return payload, nil } - firstPayload, err := parseClientPayload(firstClientMessage) + firstPayload, err := parseClientPayload(1, firstClientMessage) if err != nil { return err } @@ -513,7 +523,13 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } grokCacheIdentity := "" if account.Platform == PlatformGrok { - grokCacheIdentity, err = resolveGrokWSCacheIdentity(c, account, grokCacheSeedPayload, currentBridgePayload.originalModel) + grokCacheIdentity, err = resolveGrokWSCacheIdentity( + c, + account, + grokCacheSeedPayload, + currentBridgePayload.payloadRaw, + currentBridgePayload.originalModel, + ) if err != nil { return fmt.Errorf("resolve Grok websocket cache identity: %w", err) } @@ -573,7 +589,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } return fmt.Errorf("read client websocket request: %w", readErr) } - nextPayload, parseErr := parseClientPayload(nextClientMessage) + nextPayload, parseErr := parseClientPayload(turn+1, nextClientMessage) if parseErr != nil { return parseErr } @@ -792,7 +808,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( mappedModel := "" var mappedModelBytes []byte if originalModel != "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + mappedModel = strings.TrimSpace(gjson.GetBytes(payload, "model").String()) + if mappedModel == "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -1589,7 +1608,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return fmt.Errorf("read client websocket request: %w", readErr) } - nextPayload, parseErr := parseClientPayload(nextClientMessage) + nextPayload, parseErr := parseClientPayload(turn+1, nextClientMessage) if parseErr != nil { return parseErr } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index eb7943589..b2c7c67f6 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -286,7 +286,10 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( needModelReplace := false var mappedModelBytes []byte if originalModel != "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + mappedModel = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if mappedModel == "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -487,12 +490,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return resultWithUsage(), terminalErr } -func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte, originalModel string) (string, error) { - body, err := prepareOpenAIWSHTTPBridgeBody(payload) +func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) { + body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload) if err != nil { return "", err } - upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) + upstreamModel := resolveGrokWSUpstreamModel(account, currentPayload, originalModel) body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { return "", err @@ -502,7 +505,11 @@ func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte func resolveGrokWSUpstreamModel(account *Account, body []byte, originalModel string) string { upstreamModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) - if account != nil && originalModel != "" { + originalModel = strings.TrimSpace(originalModel) + // Shared ingress has already applied channel and account mappings when the + // body model differs from the client-facing model. Only resolve from the + // original model when the body still carries that original value. + if account != nil && originalModel != "" && (upstreamModel == "" || upstreamModel == originalModel) { if mappedModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)); mappedModel != "" { upstreamModel = mappedModel } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 73f8d1f01..802146cd3 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -520,7 +520,7 @@ func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader)) } -func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) { +func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMappedModels(t *testing.T) { gin.SetMode(gin.TestMode) bridgeResponse := func(responseID, requestID string, cachedTokens int) *http.Response { @@ -594,7 +594,14 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) ginCtx.Request = req ginCtx.Set("api_key", &APIKey{ID: 7101}) - errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token", firstMessage, nil) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token", firstMessage, &OpenAIWSIngressHooks{ + MapRequestModel: func(_ int, originalModel string) (string, error) { + if originalModel == "channel-alias" { + return "grok-4.3", nil + } + return originalModel, nil + }, + }) })) defer wsServer.Close() @@ -626,7 +633,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "resp_grok_ws_1", gjson.GetBytes(completed, "response.id").String()) writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"grok","stream":true,"previous_response_id":"resp_grok_ws_1","input":"second turn"}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"channel-alias","stream":true,"previous_response_id":"resp_grok_ws_1","input":"second turn"}`)) cancelWrite() require.NoError(t, err) @@ -637,6 +644,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "response.output_text.delta", gjson.GetBytes(delta, "type").String()) require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) require.Equal(t, "resp_grok_ws_2", gjson.GetBytes(completed, "response.id").String()) + require.Equal(t, "channel-alias", gjson.GetBytes(completed, "response.model").String()) writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"grok-4.3","stream":true,"previous_response_id":"resp_grok_ws_2","input":"third turn with a different model"}`)) @@ -666,7 +674,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[0], "model").String()) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[1], "model").String()) + require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[1], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[2], "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) @@ -677,9 +685,10 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) secondIdentity := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String() thirdIdentity := gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String() require.NotEmpty(t, firstIdentity) - require.Equal(t, firstIdentity, secondIdentity) + require.NotEmpty(t, secondIdentity) + require.NotEqual(t, firstIdentity, secondIdentity) require.NotEmpty(t, thirdIdentity) - require.NotEqual(t, firstIdentity, thirdIdentity) + require.Equal(t, secondIdentity, thirdIdentity) require.Equal(t, firstIdentity, upstream.requests[0].Header.Get(grokConversationIDHeader)) require.Equal(t, secondIdentity, upstream.requests[1].Header.Get(grokConversationIDHeader)) require.Equal(t, thirdIdentity, upstream.requests[2].Header.Get(grokConversationIDHeader)) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 4dae079b5..8d98fc78a 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -7,6 +7,7 @@ import ( "net" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -86,6 +87,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage + requestModelMu sync.RWMutex requestModel string lastResponseID string terminalEventType string @@ -164,6 +166,12 @@ func Relay( defer cancel() return upstreamConn.WriteFrame(writeCtx, msgType, payload) } + writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { + if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + } + return writeUpstream(msgType, payload) + } writeClient := func(msgType coderws.MessageType, payload []byte) error { writeCtx, cancel := context.WithTimeout(relayCtx, writeTimeout) defer cancel() @@ -215,7 +223,7 @@ func Relay( if !clientReaderStarted.CompareAndSwap(false, true) { return } - go runClientToUpstream(relayCtx, clientConn, options.ReadClientFrame, writeUpstream, markActivity, clientToUpstreamFrames, onTrace, exitCh) + go runClientToUpstream(relayCtx, clientConn, options.ReadClientFrame, writeClientFrameUpstream, markActivity, clientToUpstreamFrames, onTrace, exitCh) } if !options.StartClientAfterFirstDownstream { startClientReader() @@ -712,7 +720,7 @@ func emitTurnComplete( } requestModel := "" if state != nil { - requestModel = state.requestModel + requestModel = state.currentRequestModel() } onTurnComplete(RelayTurnResult{ RequestModel: requestModel, @@ -873,13 +881,31 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration if state == nil { return } - result.RequestModel = state.requestModel + result.RequestModel = state.currentRequestModel() result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType result.FirstTokenMs = state.firstTokenMs } +func (s *relayState) setRequestModel(model string) { + if s == nil || model == "" { + return + } + s.requestModelMu.Lock() + s.requestModel = model + s.requestModelMu.Unlock() +} + +func (s *relayState) currentRequestModel() string { + if s == nil { + return "" + } + s.requestModelMu.RLock() + defer s.requestModelMu.RUnlock() + return s.requestModel +} + func isDisconnectError(err error) bool { if err == nil { return false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5d77fe0f..c083943bc 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -472,6 +472,56 @@ func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) { require.Equal(t, 5, result.Usage.OutputTokens) } +func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make(chan RelayTurnResult, 2) + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{ + OnTurnComplete: func(turn RelayTurnResult) { + turns <- turn + }, + }) + }() + + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_sol","usage":{"input_tokens":2,"output_tokens":1}}}`), + } + firstTurn := <-turns + require.Equal(t, "gpt-5.6-sol", firstTurn.RequestModel) + + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.6-terra","input":[]}`), + } + require.Eventually(t, func() bool { + return len(upstreamConn.Writes()) == 2 + }, time.Second, 10*time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_terra","usage":{"input_tokens":3,"output_tokens":1}}}`), + } + secondTurn := <-turns + require.Equal(t, "gpt-5.6-terra", secondTurn.RequestModel) + + _ = clientConn.Close() + _ = upstreamConn.Close() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not stop after client close") + } +} + func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 934a35fcc..ae74027bc 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -25,6 +25,9 @@ type openAIWSClientFrameConn struct { interTurnIdleTimeout time.Duration interTurnStarted chan struct{} waitingForNextTurn atomic.Bool + // The relay observes upstream payloads, while clients must keep seeing the + // model identifier they supplied for the current turn. + restoreResponseModel func([]byte) []byte } // openAIWSPolicyEnforcingFrameConn wraps a client-side FrameConn and runs @@ -133,6 +136,8 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload [] type openAIWSPassthroughUsageMeta struct { serviceTier atomic.Pointer[string] reasoningEffort atomic.Pointer[string] + requestModel atomic.Pointer[string] + upstreamModel atomic.Pointer[string] // 仅在 client->upstream filter goroutine 中读写;Load 侧通过上方原子指针同步。 sessionRequestModel string @@ -154,6 +159,7 @@ func (m *openAIWSPassthroughUsageMeta) initFromFirstFrame(policyOutput []byte, m } m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput)) m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, mappedModel, m.sessionRequestModel)) + m.storeTurnModels(m.sessionRequestModel, policyOutput) } func (m *openAIWSPassthroughUsageMeta) updateSessionRequestModel(payload []byte) { @@ -181,6 +187,51 @@ func (m *openAIWSPassthroughUsageMeta) updateFromResponseCreate(policyOutput []b } m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput)) m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, mappedModel, requestModelForFrame)) + m.storeTurnModels(requestModelForFrame, policyOutput) +} + +func (m *openAIWSPassthroughUsageMeta) storeTurnModels(requestModel string, upstreamPayload []byte) { + if m == nil { + return + } + requestModel = strings.TrimSpace(requestModel) + upstreamModel := strings.TrimSpace(gjson.GetBytes(upstreamPayload, "model").String()) + if upstreamModel == "" { + upstreamModel = requestModel + } + m.requestModel.Store(openAIWSTrimmedStringPtr(requestModel)) + m.upstreamModel.Store(openAIWSTrimmedStringPtr(upstreamModel)) +} + +func (m *openAIWSPassthroughUsageMeta) turnModels(fallback string) (string, string) { + requestModel := strings.TrimSpace(fallback) + upstreamModel := requestModel + if m == nil { + return requestModel, upstreamModel + } + if current := m.requestModel.Load(); current != nil && strings.TrimSpace(*current) != "" { + requestModel = strings.TrimSpace(*current) + } + if current := m.upstreamModel.Load(); current != nil && strings.TrimSpace(*current) != "" { + upstreamModel = strings.TrimSpace(*current) + } + return requestModel, upstreamModel +} + +func openAIWSTrimmedStringPtr(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func openAIWSDifferentModel(requestModel, upstreamModel string) string { + upstreamModel = strings.TrimSpace(upstreamModel) + if upstreamModel == "" || upstreamModel == strings.TrimSpace(requestModel) { + return "" + } + return upstreamModel } func openAIWSPassthroughRequestModelForFrame(payload []byte) string { @@ -585,6 +636,9 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw if normalized, changed := normalizeCompletedImageGenerationStatus(payload); changed { payload = normalized } + if c.restoreResponseModel != nil { + payload = c.restoreResponseModel(payload) + } } return c.conn.Write(ctx, msgType, payload) } @@ -655,10 +709,25 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // negotiated at session.update time. Without this fallback, an empty // model would miss any admin-configured model whitelist and be silently // passed through, defeating that policy on every frame after the first. - capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage) initialRequestModel := "" if hooks != nil { - initialRequestModel = hooks.InitialRequestModel + initialRequestModel = strings.TrimSpace(hooks.InitialRequestModel) + } + if initialRequestModel == "" { + initialRequestModel = openAIWSPassthroughRequestModelForFrame(firstClientMessage) + } + if hooks != nil && hooks.MapRequestModel != nil { + mappedModel, mapErr := hooks.MapRequestModel(1, initialRequestModel) + if mapErr != nil { + return mapErr + } + if mappedModel = strings.TrimSpace(mappedModel); mappedModel != "" { + firstClientMessage = s.ReplaceModelInBody(firstClientMessage, mappedModel) + } + } + capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage) + if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { + firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) @@ -839,6 +908,14 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( controlCtx: ctx, interTurnIdleTimeout: s.openAIWSIngressInterTurnIdleTimeout(), interTurnStarted: make(chan struct{}, 1), + restoreResponseModel: func(payload []byte) []byte { + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if !openAIWSEventMayContainModel(eventType) { + return payload + } + requestModel, upstreamModel := usageMeta.turnModels("") + return replaceOpenAIWSMessageModel(payload, upstreamModel, requestModel) + }, } policyClientConn := &openAIWSPolicyEnforcingFrameConn{ inner: clientFrameConn, @@ -878,17 +955,29 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } } - if isResponseCreate && hooks != nil && hooks.BeforeRequest != nil { - turnNo := int(completedTurns.Load()) + 1 - if turnNo < 2 { - turnNo = 2 + turnNo := int(completedTurns.Load()) + 1 + if turnNo < 2 { + turnNo = 2 + } + requestModelForThisFrame := "" + if isResponseCreate { + requestModelForThisFrame = usageMeta.requestModelForFrame(payload) + if requestModelForThisFrame == "" { + requestModelForThisFrame = capturedSessionModel } - requestModel := usageMeta.requestModelForFrame(payload) - if requestModel == "" { - requestModel = capturedSessionModel + if hooks != nil && hooks.BeforeRequest != nil { + if err := hooks.BeforeRequest(turnNo, payload, requestModelForThisFrame); err != nil { + return payload, nil, err + } } - if err := hooks.BeforeRequest(turnNo, payload, requestModel); err != nil { - return payload, nil, err + if hooks != nil && hooks.MapRequestModel != nil { + upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) + if err != nil { + return payload, nil, err + } + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + payload = s.ReplaceModelInBody(payload, upstreamModel) + } } } // 在评估策略前先刷新 capturedSessionModel:客户端可能通过 @@ -902,7 +991,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( capturedSessionModel = updated } usageMeta.updateSessionRequestModel(payload) - requestModelForThisFrame := usageMeta.requestModelForFrame(payload) + if requestModelForThisFrame == "" { + requestModelForThisFrame = usageMeta.requestModelForFrame(payload) + } // Per-frame model first; if the client omits "model" on a // follow-up frame (legal in Realtime), fall back to the // session-level model captured from the first frame so the @@ -912,6 +1003,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if model == "" { model = capturedSessionModel } + if isResponseCreate && model != "" && model != strings.TrimSpace(gjson.GetBytes(payload, "model").String()) { + payload = s.ReplaceModelInBody(payload, model) + } out, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, model, payload) // 多轮 passthrough usage:仅在成功(non-block / non-err) // 的 response.create 帧上更新 usageMeta,使用 @@ -999,6 +1093,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, OnTurnComplete: func(turn openaiwsv2.RelayTurnResult) { turnNo := int(completedTurns.Add(1)) + turnRequestModel, turnUpstreamModel := usageMeta.turnModels(turn.RequestModel) turnResult := &OpenAIForwardResult{ RequestID: turn.RequestID, Usage: OpenAIUsage{ @@ -1008,7 +1103,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: turn.Usage.CacheReadInputTokens, ImageOutputTokens: turn.Usage.ImageOutputTokens, }, - Model: turn.RequestModel, + Model: turnRequestModel, + UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, @@ -1019,11 +1115,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( FirstTokenMs: turn.FirstTokenMs, } logOpenAIWSV2Passthrough( - "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", + "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s turn_requested_model=%s turn_upstream_model=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", account.ID, turnNo, truncateOpenAIWSLogValue(turnResult.RequestID, openAIWSIDValueMaxLen), truncateOpenAIWSLogValue(turn.TerminalEventType, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(turnRequestModel, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(turnUpstreamModel, openAIWSLogValueMaxLen), turnResult.Duration.Milliseconds(), openAIWSFirstTokenMsForLog(turnResult.FirstTokenMs), turnResult.Usage.InputTokens, @@ -1114,6 +1212,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return NewOpenAIWSClientCloseError(status, reason, cause) } + resultRequestModel, resultUpstreamModel := usageMeta.turnModels(relayResult.RequestModel) result := &OpenAIForwardResult{ RequestID: relayResult.RequestID, Usage: OpenAIUsage{ @@ -1123,7 +1222,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, ImageOutputTokens: relayResult.Usage.ImageOutputTokens, }, - Model: relayResult.RequestModel, + Model: resultRequestModel, + UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 3518fbe2e..792a842a2 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -144,28 +144,32 @@ type RefundResult struct { } type DashboardStats struct { - TodayAmount float64 `json:"today_amount"` - TotalAmount float64 `json:"total_amount"` - TodayCount int `json:"today_count"` - TotalCount int `json:"total_count"` - AvgAmount float64 `json:"avg_amount"` - PendingOrders int `json:"pending_orders"` + TodayAmount CurrencyAmounts `json:"today_amount"` + TotalAmount CurrencyAmounts `json:"total_amount"` + TodayCount int `json:"today_count"` + TotalCount int `json:"total_count"` + AvgAmount CurrencyAmounts `json:"avg_amount"` + PendingOrders int `json:"pending_orders"` DailySeries []DailyStats `json:"daily_series"` PaymentMethods []PaymentMethodStat `json:"payment_methods"` - TopUsers []TopUserStat `json:"top_users"` + TopUsers TopUsersByCurrency `json:"top_users"` } +// CurrencyAmounts holds payment amounts keyed by their ISO 4217 currency. +// Amounts in different currencies must never be added together. +type CurrencyAmounts map[string]float64 + type DailyStats struct { - Date string `json:"date"` - Amount float64 `json:"amount"` - Count int `json:"count"` + Date string `json:"date"` + Amount CurrencyAmounts `json:"amount"` + Count int `json:"count"` } type PaymentMethodStat struct { - Type string `json:"type"` - Amount float64 `json:"amount"` - Count int `json:"count"` + Type string `json:"type"` + Amount CurrencyAmounts `json:"amount"` + Count int `json:"count"` } type TopUserStat struct { @@ -174,6 +178,10 @@ type TopUserStat struct { Amount float64 `json:"amount"` } +// TopUsersByCurrency contains an independent ranked user list for each +// currency. A single cross-currency leaderboard would be misleading. +type TopUsersByCurrency map[string][]TopUserStat + // --- Service --- type PaymentService struct { diff --git a/backend/internal/service/payment_stats.go b/backend/internal/service/payment_stats.go index d206b2717..9dc48e012 100644 --- a/backend/internal/service/payment_stats.go +++ b/backend/internal/service/payment_stats.go @@ -54,22 +54,27 @@ func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*Dash } func computeBasicStats(st *DashboardStats, orders []*dbent.PaymentOrder, todayStart time.Time) { - var totalAmount, todayAmount float64 + st.TotalAmount = make(CurrencyAmounts) + st.TodayAmount = make(CurrencyAmounts) + st.AvgAmount = make(CurrencyAmounts) + currencyCounts := make(map[string]int) var todayCount int for _, o := range orders { - totalAmount += o.PayAmount + currency := PaymentOrderCurrency(o) + st.TotalAmount[currency] += o.PayAmount + currencyCounts[currency]++ if o.PaidAt != nil && !o.PaidAt.Before(todayStart) { - todayAmount += o.PayAmount + st.TodayAmount[currency] += o.PayAmount todayCount++ } } - st.TotalAmount = math.Round(totalAmount*100) / 100 - st.TodayAmount = math.Round(todayAmount*100) / 100 st.TotalCount = len(orders) st.TodayCount = todayCount - if st.TotalCount > 0 { - st.AvgAmount = math.Round(totalAmount/float64(st.TotalCount)*100) / 100 + for currency, totalAmount := range st.TotalAmount { + st.AvgAmount[currency] = roundAmount(totalAmount / float64(currencyCounts[currency])) } + roundCurrencyAmounts(st.TotalAmount) + roundCurrencyAmounts(st.TodayAmount) } func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) []DailyStats { @@ -81,20 +86,20 @@ func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) [ date := o.PaidAt.Format("2006-01-02") ds, ok := dailyMap[date] if !ok { - ds = &DailyStats{Date: date} + ds = &DailyStats{Date: date, Amount: make(CurrencyAmounts)} dailyMap[date] = ds } - ds.Amount += o.PayAmount + ds.Amount[PaymentOrderCurrency(o)] += o.PayAmount ds.Count++ } series := make([]DailyStats, 0, days) for i := 0; i < days; i++ { date := since.AddDate(0, 0, i+1).Format("2006-01-02") if ds, ok := dailyMap[date]; ok { - ds.Amount = math.Round(ds.Amount*100) / 100 + roundCurrencyAmounts(ds.Amount) series = append(series, *ds) } else { - series = append(series, DailyStats{Date: date}) + series = append(series, DailyStats{Date: date, Amount: make(CurrencyAmounts)}) } } return series @@ -105,49 +110,71 @@ func buildMethodDistribution(orders []*dbent.PaymentOrder) []PaymentMethodStat { for _, o := range orders { ms, ok := methodMap[o.PaymentType] if !ok { - ms = &PaymentMethodStat{Type: o.PaymentType} + ms = &PaymentMethodStat{Type: o.PaymentType, Amount: make(CurrencyAmounts)} methodMap[o.PaymentType] = ms } - ms.Amount += o.PayAmount + ms.Amount[PaymentOrderCurrency(o)] += o.PayAmount ms.Count++ } methods := make([]PaymentMethodStat, 0, len(methodMap)) for _, ms := range methodMap { - ms.Amount = math.Round(ms.Amount*100) / 100 + roundCurrencyAmounts(ms.Amount) methods = append(methods, *ms) } + sort.Slice(methods, func(i, j int) bool { + return methods[i].Type < methods[j].Type + }) return methods } -func buildTopUsers(orders []*dbent.PaymentOrder) []TopUserStat { - userMap := make(map[int64]*TopUserStat) +func buildTopUsers(orders []*dbent.PaymentOrder) TopUsersByCurrency { + userMap := make(map[string]map[int64]*TopUserStat) for _, o := range orders { - us, ok := userMap[o.UserID] + currency := PaymentOrderCurrency(o) + users, ok := userMap[currency] + if !ok { + users = make(map[int64]*TopUserStat) + userMap[currency] = users + } + us, ok := users[o.UserID] if !ok { us = &TopUserStat{UserID: o.UserID, Email: o.UserEmail} - userMap[o.UserID] = us + users[o.UserID] = us } us.Amount += o.PayAmount } - userList := make([]*TopUserStat, 0, len(userMap)) - for _, us := range userMap { - us.Amount = math.Round(us.Amount*100) / 100 - userList = append(userList, us) - } - sort.Slice(userList, func(i, j int) bool { - return userList[i].Amount > userList[j].Amount - }) - limit := topUsersLimit - if len(userList) < limit { - limit = len(userList) - } - result := make([]TopUserStat, 0, limit) - for i := 0; i < limit; i++ { - result = append(result, *userList[i]) + result := make(TopUsersByCurrency, len(userMap)) + for currency, users := range userMap { + userList := make([]*TopUserStat, 0, len(users)) + for _, us := range users { + us.Amount = roundAmount(us.Amount) + userList = append(userList, us) + } + sort.Slice(userList, func(i, j int) bool { + return userList[i].Amount > userList[j].Amount + }) + limit := topUsersLimit + if len(userList) < limit { + limit = len(userList) + } + result[currency] = make([]TopUserStat, 0, limit) + for i := 0; i < limit; i++ { + result[currency] = append(result[currency], *userList[i]) + } } return result } +func roundCurrencyAmounts(amounts CurrencyAmounts) { + for currency, amount := range amounts { + amounts[currency] = roundAmount(amount) + } +} + +func roundAmount(amount float64) float64 { + return math.Round(amount*100) / 100 +} + // --- Audit Logs --- func (s *PaymentService) writeAuditLog(ctx context.Context, oid int64, action, op string, detail map[string]any) { diff --git a/backend/internal/service/payment_stats_test.go b/backend/internal/service/payment_stats_test.go new file mode 100644 index 000000000..267719288 --- /dev/null +++ b/backend/internal/service/payment_stats_test.go @@ -0,0 +1,84 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/stretchr/testify/require" +) + +func TestComputeBasicStatsGroupsAmountsByCurrency(t *testing.T) { + t.Parallel() + + todayStart := time.Date(2026, time.July, 25, 0, 0, 0, 0, time.UTC) + yesterday := todayStart.Add(-time.Hour) + today := todayStart.Add(time.Hour) + orders := []*dbent.PaymentOrder{ + paymentStatsTestOrder(1, "alice@example.com", "CNY", 10, &today), + paymentStatsTestOrder(2, "bob@example.com", "USD", 10, &today), + paymentStatsTestOrder(1, "alice@example.com", "CNY", 5, &yesterday), + } + + stats := &DashboardStats{} + computeBasicStats(stats, orders, todayStart) + + require.Equal(t, CurrencyAmounts{"CNY": 15, "USD": 10}, stats.TotalAmount) + require.Equal(t, CurrencyAmounts{"CNY": 10, "USD": 10}, stats.TodayAmount) + require.Equal(t, CurrencyAmounts{"CNY": 7.5, "USD": 10}, stats.AvgAmount) + require.Equal(t, 3, stats.TotalCount) + require.Equal(t, 2, stats.TodayCount) +} + +func TestPaymentDashboardBreakdownsGroupAmountsAndRankingsByCurrency(t *testing.T) { + t.Parallel() + + firstDay := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + secondDay := firstDay.AddDate(0, 0, 1) + orders := []*dbent.PaymentOrder{ + paymentStatsTestOrder(1, "alice@example.com", "CNY", 5.555, &firstDay), + paymentStatsTestOrder(2, "bob@example.com", "CNY", 10, &firstDay), + paymentStatsTestOrder(1, "alice@example.com", "USD", 20, &secondDay), + paymentStatsTestOrder(2, "bob@example.com", "USD", 10, &secondDay), + } + orders[0].PaymentType = "stripe" + orders[1].PaymentType = "stripe" + orders[2].PaymentType = "stripe" + orders[3].PaymentType = "alipay" + + daily := buildDailySeries(orders, firstDay.AddDate(0, 0, -1), 2) + require.Equal(t, []DailyStats{ + {Date: "2026-07-24", Amount: CurrencyAmounts{"CNY": 15.56}, Count: 2}, + {Date: "2026-07-25", Amount: CurrencyAmounts{"USD": 30}, Count: 2}, + }, daily) + + methods := buildMethodDistribution(orders) + require.Equal(t, []PaymentMethodStat{ + {Type: "alipay", Amount: CurrencyAmounts{"USD": 10}, Count: 1}, + {Type: "stripe", Amount: CurrencyAmounts{"CNY": 15.56, "USD": 20}, Count: 3}, + }, methods) + + users := buildTopUsers(orders) + require.Equal(t, TopUsersByCurrency{ + "CNY": { + {UserID: 2, Email: "bob@example.com", Amount: 10}, + {UserID: 1, Email: "alice@example.com", Amount: 5.56}, + }, + "USD": { + {UserID: 1, Email: "alice@example.com", Amount: 20}, + {UserID: 2, Email: "bob@example.com", Amount: 10}, + }, + }, users) +} + +func paymentStatsTestOrder(userID int64, email, currency string, amount float64, paidAt *time.Time) *dbent.PaymentOrder { + return &dbent.PaymentOrder{ + UserID: userID, + UserEmail: email, + PayAmount: amount, + PaidAt: paidAt, + ProviderSnapshot: map[string]any{"currency": currency}, + } +} diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 636bd690c..c969ec18d 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -695,16 +695,24 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing } func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { - // Prefer canonical model name first (this also improves billing compatibility with "models/xxx"). - candidates := []string{ - normalizeModelNameForPricing(modelLower), + rawCandidates := []string{ modelLower, - } - candidates = append(candidates, strings.TrimPrefix(modelLower, "models/"), lastSegment(modelLower), lastSegment(strings.TrimPrefix(modelLower, "models/")), - ) + } + normalized := normalizeModelNameForPricing(modelLower) + + // A tier-specific entry should take precedence when the pricing catalog gains + // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // so the normalized base remains the fallback after the exact aliases. + candidates := rawCandidates + if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { + candidates = append(candidates, normalized) + } else { + // Prefer canonical model names for all other aliases (including models/xxx). + candidates = append([]string{normalized}, candidates...) + } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) @@ -752,6 +760,20 @@ func normalizeModelNameForPricing(model string) string { } return canonical } + return normalizeGeminiThinkingTierAlias(model) +} + +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// thinking-tier model IDs to the public base model. The tier controls reasoning +// behavior, not the published token rate, so this keeps -high/-low/-medium and +// -tiered requests on the same price card as gemini-3.6-flash. +func normalizeGeminiThinkingTierAlias(model string) string { + const baseModel = "gemini-3.6-flash" + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } + } return model } diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 88553f0fb..48e5159ad 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -454,6 +454,83 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } +func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { + basePricing := &LiteLLMModelPricing{ + InputCostPerToken: 1.5e-6, + OutputCostPerToken: 7.5e-6, + CacheReadInputTokenCost: 0.15e-6, + } + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gemini-3.6-flash": basePricing, + }} + + for _, model := range []string{ + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", + } { + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + }) + } +} + +func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { + basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} + tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gemini-3.6-flash": basePricing, + "gemini-3.6-flash-low": tierPricing, + }} + + require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) +} + +func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { + svc := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + + for _, model := range []string{ + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", + } { + t.Run(model, func(t *testing.T) { + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 1.5, cost.InputCost, 1e-12) + require.InDelta(t, 7.5, cost.OutputCost, 1e-12) + require.InDelta(t, 0.15, cost.CacheReadCost, 1e-12) + require.InDelta(t, 9.15, cost.TotalCost, 1e-12) + }) + } +} + +func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + pricingSvc := &PricingService{} + pricingData, err := pricingSvc.parsePricingData(data) + require.NoError(t, err) + pricingSvc.pricingData = pricingData + billingSvc := NewBillingService(&config.Config{}, pricingSvc) + + for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-low", "gemini-3.6-flash-high"} { + t.Run(model, func(t *testing.T) { + pricing, err := billingSvc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 1.5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 7.5e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.15e-6, pricing.CacheReadPricePerToken, 1e-12) + }) + } +} + func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) require.NoError(t, err) diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7449a3de6..2364b8948 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "math" "strconv" "strings" @@ -15,22 +16,50 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +// OmittedSettingKeys marks setting keys the caller's payload never carried. +// SystemSettings is a plain struct, so a field the caller omitted arrives as a +// zero value and is indistinguishable from a deliberate clear. Listing the key +// here drops it from the write, leaving the stored value in place. +// +// A nil or empty set keeps whole-document semantics: every key is written. +type OmittedSettingKeys map[string]struct{} + +func (o OmittedSettingKeys) dropFrom(updates map[string]string) { + for key := range o { + delete(updates, key) + } +} + // UpdateSettings 更新系统设置 func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { + return s.UpdateSettingsOmitting(ctx, settings, nil) +} + +// UpdateSettingsOmitting persists system settings, leaving the keys in omitted +// at their stored value. +func (s *SettingService) UpdateSettingsOmitting(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) error { updates, err := s.buildSystemSettingsUpdates(ctx, settings) if err != nil { return err } + omitted.dropFrom(updates) - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return err } - return err + s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + return nil } // UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { + return s.UpdateSettingsWithAuthSourceDefaultsOmitting(ctx, settings, authDefaults, nil) +} + +// UpdateSettingsWithAuthSourceDefaultsOmitting persists system settings and +// auth-source defaults in a single write, leaving the keys in omitted at their +// stored value. +func (s *SettingService) UpdateSettingsWithAuthSourceDefaultsOmitting(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings, omitted OmittedSettingKeys) error { updates, err := s.buildSystemSettingsUpdates(ctx, settings) if err != nil { return err @@ -43,12 +72,30 @@ func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Contex for key, value := range authSourceUpdates { updates[key] = value } + omitted.dropFrom(updates) - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return err } - return err + s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + return nil +} + +// refreshCachedSettingsAfterWrite keeps the in-process caches in step with the +// write that just landed. A partial payload carries zero values for the fields +// it omitted, so in that case the caches are rebuilt from storage rather than +// from the request struct. +func (s *SettingService) refreshCachedSettingsAfterWrite(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) { + if len(omitted) == 0 { + s.refreshCachedSettings(settings) + return + } + stored, err := s.GetAllSettings(ctx) + if err != nil { + slog.Warn("refresh cached settings after partial update failed", "error", err) + return + } + s.refreshCachedSettings(stored) } func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { diff --git a/backend/internal/service/token_refresh_pool_health_test.go b/backend/internal/service/token_refresh_pool_health_test.go index f4950d879..a9bd760d9 100644 --- a/backend/internal/service/token_refresh_pool_health_test.go +++ b/backend/internal/service/token_refresh_pool_health_test.go @@ -630,7 +630,29 @@ func TestTokenRefreshService_SaturatedProviderPreservesConcurrencyAndActualQPSSt starts := refresher.startsSnapshot() require.Len(t, starts, attemptCount) configuredSpacing := time.Second / time.Duration(providerQPS) - minimumObservedSpacing := configuredSpacing - 10*time.Millisecond + + // The floor is a tenth of the configured spacing, not spacing minus a few + // milliseconds. Each start timestamp is taken after the rate gate releases + // the goroutine, so scheduler delay can compress one observed gap without + // the gate having done anything wrong: this assertion failed on a loaded + // machine at 13ms and again at 37ms against a 40ms floor, both times with + // the gate pacing correctly. + // + // A tenth still separates the two cases by a wide margin. Measured with + // providerQPS=20 (50ms spacing), 8 attempts, providerConcurrency=2: + // + // gate at 50ms -> minimum observed gap 49.97ms + // gate at 0 -> minimum observed gap 22µs + // + // So an unpaced gate lands three orders of magnitude below the 5ms floor + // and is still caught, while jitter on a busy machine has room to move. + // + // Do not swap this for an assertion on the total span of the starts: the + // span is dominated by how long each attempt takes under + // providerConcurrency, not by the gate. Same measurement — 471ms paced + // against 241ms unpaced — so a span check passes with the gate disabled and + // tests nothing. + minimumObservedSpacing := configuredSpacing / 10 actualMinimumSpacing := starts[1].Sub(starts[0]) for i := 1; i < len(starts); i++ { spacing := starts[i].Sub(starts[i-1]) diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index 7cc8a713c..b431b50ae 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -10,16 +10,6 @@ func optionalTrimmedStringPtr(raw string) *string { return &trimmed } -// optionalNonEqualStringPtr returns a pointer to value if it is non-empty and -// differs from compare; otherwise nil. Used to store upstream_model only when -// it differs from the requested model. -func optionalNonEqualStringPtr(value, compare string) *string { - if value == "" || value == compare { - return nil - } - return &value -} - func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed 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 8c3e95aab..21180fb56 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -2339,6 +2339,23 @@ "supports_web_search": true, "web_search_billing_unit": "per_query" }, + "gemini-3.6-flash": { + "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_priority": 2.7e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_priority": 2.7e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_priority": 1.35e-05, + "source": "https://ai.google.dev/gemini-api/docs/latest-model", + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_service_tier": true + }, "gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-embedding-models", diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 079b5f07e..77e1bf4c5 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -67,7 +67,14 @@ api.sub2api.com { gzip 6 minimum_length 256 match { - header Content-Type text/* + # 不使用 text/*,否则 text/event-stream 会被压缩并缓冲到流结束 + header Content-Type text/css* + header Content-Type text/csv* + header Content-Type text/html* + header Content-Type text/javascript* + header Content-Type text/markdown* + header Content-Type text/plain* + header Content-Type text/xml* header Content-Type application/json* header Content-Type application/javascript* header Content-Type application/xml* diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index 956507c4f..5e9ab10fb 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -128,6 +128,20 @@ server { } ``` +If Nginx gzip is enabled in the `http` block, keep `text/event-stream` out of +`gzip_types` and do not use `gzip_types *` for Sub2API. The +`proxy_buffering off` setting above prevents proxy buffering, but it does not +disable the gzip response filter. Use an explicit list for ordinary responses: + +```nginx +gzip on; +gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; +``` + +If a shared global configuration cannot exclude SSE by content type, set +`gzip off;` in the locations serving streaming API routes. This leaves gzip +available for the web UI and static assets. + Do not use an incoming `$http_x_forwarded_for` value unless Nginx real-IP processing is restricted to explicit trusted proxy CIDRs. @@ -140,6 +154,17 @@ the TCP peer. It is therefore a direct-to-Caddy baseline. Do not use its attributed to a CDN egress address, collapsing rejection aggregation and the invalid-auth limiter onto unrelated users. +The bundled Caddy configuration leaves `flush_interval` unset so Caddy can +automatically flush `text/event-stream` responses while still propagating +client cancellation upstream. Do not set it globally: positive values can add +streaming latency, while Caddy 2.6.2's special `-1` mode also causes +reverse-proxied requests to continue after clients disconnect. The +configuration uses an explicit response content-type list for compression. Do +not replace that list with `text/*` or the shorthand `encode gzip zstd`: both +match `text/event-stream` and can buffer SSE until the response ends. Keep +streaming responses uncompressed while retaining compression for the web UI, +JSON, and static assets. + For a CDN deployment, first firewall the origin so only current CDN egress CIDRs can connect. Then configure those exact ranges as Caddy trusted proxies and derive upstream headers from Caddy's parsed `{client_ip}`. For example: diff --git a/deploy/test-caddyfile-cache.sh b/deploy/test-caddyfile-cache.sh index 789e48ee2..41e5e255c 100755 --- a/deploy/test-caddyfile-cache.sh +++ b/deploy/test-caddyfile-cache.sh @@ -4,15 +4,82 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) caddyfile="$repo_root/deploy/Caddyfile" active_config=$(sed 's/[[:space:]]*#.*$//' "$caddyfile") +normalized_config=$(printf '%s\n' "$active_config" | awk ' + NF > 0 { + for (field = 1; field <= NF; field++) { + token = $field + sub(/^["`]/, "", token) + sub(/["`]$/, "", token) + printf "%s%s", (field == 1 ? "" : " "), token + } + print "" + } +') -if printf '%s\n' "$active_config" | grep -Eiq 'Cache-Control.*immutable'; then +# Keep one canonical encode block instead of reimplementing Caddy matcher semantics. +expected_encode_block=$(cat <<'EOF' +encode { +zstd +gzip 6 +minimum_length 256 +match { +header Content-Type text/css* +header Content-Type text/csv* +header Content-Type text/html* +header Content-Type text/javascript* +header Content-Type text/markdown* +header Content-Type text/plain* +header Content-Type text/xml* +header Content-Type application/json* +header Content-Type application/javascript* +header Content-Type application/xml* +header Content-Type application/rss+xml* +header Content-Type image/svg+xml* +} +} +EOF +) + +if printf '%s\n' "$normalized_config" | grep -Eiq 'cache-control.*immutable'; then echo "Caddyfile must not force immutable caching; the backend owns asset cache policy" >&2 exit 1 fi -if ! printf '%s\n' "$active_config" | grep -Eq '^[[:space:]]*reverse_proxy[[:space:]]+localhost:8080'; then +if ! printf '%s\n' "$normalized_config" | grep -Eq '^reverse_proxy localhost:8080([[:space:]]|$)'; then echo "Caddyfile must continue proxying all application routes to localhost:8080" >&2 exit 1 fi -echo "Caddyfile preserves backend Cache-Control policy and reverse_proxy routing" +if printf '%s\n' "$normalized_config" | grep -Eq '^import([[:space:]]|$)'; then + echo "Caddyfile must not import configuration outside this canonical policy check" >&2 + exit 1 +fi + +if printf '%s\n' "$normalized_config" | grep -Eiq '^flush_interval([[:space:]]|$)'; then + echo "Caddyfile must leave flush_interval unset so SSE auto-flushing and client cancellation remain intact" >&2 + exit 1 +fi + +encode_directive_count=$(printf '%s\n' "$normalized_config" | awk '$1 == "encode" { count++ } END { print count + 0 }') +if [ "$encode_directive_count" -ne 1 ]; then + echo "Caddyfile must contain exactly one explicit encode block" >&2 + exit 1 +fi + +actual_encode_block=$(printf '%s\n' "$normalized_config" | awk ' + $1 == "encode" { in_block = 1 } + in_block { + print + for (field = 1; field <= NF; field++) { + if ($field == "{") depth++ + if ($field == "}") depth-- + } + if (depth == 0) exit + } +') +if [ "$actual_encode_block" != "$expected_encode_block" ]; then + echo "Caddyfile encode block must keep the canonical non-SSE compression policy" >&2 + exit 1 +fi + +echo "Caddyfile preserves backend cache policy, SSE streaming, and non-SSE compression" diff --git a/frontend/src/components/admin/payment/DailyRevenueChart.vue b/frontend/src/components/admin/payment/DailyRevenueChart.vue index 77583da17..6e2544f99 100644 --- a/frontend/src/components/admin/payment/DailyRevenueChart.vue +++ b/frontend/src/components/admin/payment/DailyRevenueChart.vue @@ -33,31 +33,43 @@ import { } from 'chart.js' import { Line } from 'vue-chartjs' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' +import type { DailyPaymentStats } from '@/types/payment' ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler) const { t } = useI18n() const props = defineProps<{ - data: { date: string; amount: number; count: number }[] + data: DailyPaymentStats[] loading?: boolean }>() +const colors = [ + ['rgb(59, 130, 246)', 'rgba(59, 130, 246, 0.1)'], + ['rgb(168, 85, 247)', 'rgba(168, 85, 247, 0.1)'], + ['rgb(245, 158, 11)', 'rgba(245, 158, 11, 0.1)'], + ['rgb(239, 68, 68)', 'rgba(239, 68, 68, 0.1)'], +] + const chartData = computed(() => { if (!props.data || props.data.length === 0) return null + const currencies = [...new Set(props.data.flatMap(day => Object.keys(day.amount)))].sort() return { labels: props.data.map(d => d.date), datasets: [ - { - label: t('payment.admin.revenue'), - data: props.data.map(d => d.amount), - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - fill: true, - tension: 0.3, - pointRadius: 3, - pointHoverRadius: 5, - }, + ...currencies.map((currency, index) => { + const [borderColor, backgroundColor] = colors[index % colors.length] + return { + label: `${currency} ${t('payment.admin.revenue')}`, + data: props.data.map(day => day.amount[currency] || 0), + borderColor, + backgroundColor, + fill: true, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 5, + } + }), { label: t('payment.admin.orderCount'), data: props.data.map(d => d.count), diff --git a/frontend/src/components/admin/payment/OrderStatsCards.vue b/frontend/src/components/admin/payment/OrderStatsCards.vue index 0f7ec956d..1fece5fb5 100644 --- a/frontend/src/components/admin/payment/OrderStatsCards.vue +++ b/frontend/src/components/admin/payment/OrderStatsCards.vue @@ -8,7 +8,9 @@

{{ t('payment.admin.todayRevenue') }}

-

${{ formatMoney(stats.today_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

{{ stats.today_count }} {{ t('payment.admin.orders') }}

@@ -24,7 +26,9 @@

{{ t('payment.admin.totalRevenue') }}

-

${{ formatMoney(stats.total_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

{{ stats.total_count }} {{ t('payment.admin.orders') }}

@@ -53,7 +57,9 @@

{{ t('payment.admin.avgAmount') }}

-

${{ formatMoney(stats.avg_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

@@ -63,7 +69,7 @@ diff --git a/frontend/src/components/admin/payment/PaymentMethodChart.vue b/frontend/src/components/admin/payment/PaymentMethodChart.vue index dd6909e95..00b3178f2 100644 --- a/frontend/src/components/admin/payment/PaymentMethodChart.vue +++ b/frontend/src/components/admin/payment/PaymentMethodChart.vue @@ -18,20 +18,23 @@ {{ t('payment.methods.' + method.type, method.type) }} -
- - ${{ method.amount.toFixed(2) }} +
+ + {{ formatMoney(currency, amount) }} ({{ method.count }})
-
-
+
+ {{ currency }} +
+
+
@@ -41,11 +44,12 @@ diff --git a/frontend/src/components/admin/payment/TopUsersLeaderboard.vue b/frontend/src/components/admin/payment/TopUsersLeaderboard.vue index 1c85fcf16..ca635735d 100644 --- a/frontend/src/components/admin/payment/TopUsersLeaderboard.vue +++ b/frontend/src/components/admin/payment/TopUsersLeaderboard.vue @@ -4,31 +4,34 @@ {{ t('payment.admin.topUsers') }}
{{ t('payment.admin.noData') }}
-
-
- - {{ idx + 1 }} +
+

{{ currency }}

+
+
+ + {{ idx + 1 }} + + {{ user.email }} +
+ + {{ formatMoney(currency, user.amount) }} - {{ user.email }}
- - ${{ user.amount.toFixed(2) }} -
@@ -36,11 +39,12 @@ diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 6a5fad626..0965567ce 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -523,5 +523,7 @@ const setUserKeyword = (email: string) => { showUserDropdown.value = false } -defineExpose({ setUserKeyword }) +const getUserSearchRevision = () => userSearchSequence + +defineExpose({ getUserSearchRevision, setUserKeyword }) diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 2de45c6a5..23130d3fd 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -2,7 +2,10 @@
- +
@@ -168,6 +171,151 @@
{{ columns.name }}
+ +
+
+ +
+
+ +

{{ emptyLabel }}

+
+
+
+

+ {{ channel.name }} +

+

+ {{ channel.description || '-' }} +

+
+ +
+
+ + + {{ section.platform }} + + +
+
+
+ {{ columns.groups }} +
+
+
+ + + {{ t('availableChannels.exclusive') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+
+ + + {{ t('availableChannels.public') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+ - +
+
+ +
+
+ {{ columns.supportedModels }} +
+
+ + + {{ noModelsLabel }} + +
+
+
+
+
+
+
diff --git a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts index ecadecf56..82f8c2087 100644 --- a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts +++ b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts @@ -2,7 +2,19 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { createPinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import AvailableChannelsTable from '../AvailableChannelsTable.vue' +import type { UserAvailableChannel } from '@/api/channels' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AvailableChannelsTable.vue') const componentSource = readFileSync(componentPath, 'utf8') @@ -18,3 +30,150 @@ describe('AvailableChannelsTable scroll integration', () => { expect(componentSource).not.toMatch(/
/) }) }) + +const rows: UserAvailableChannel[] = [ + { + name: 'Primary channel', + description: 'Fast and reliable access', + platforms: [ + { + platform: 'anthropic', + groups: [ + { + id: 1, + name: 'Exclusive Pro', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1.2, + peak_rate_enabled: true, + peak_start: '08:00', + peak_end: '10:00', + peak_rate_multiplier: 1.5, + is_exclusive: true, + }, + { + id: 2, + name: 'Public', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1, + peak_rate_enabled: false, + peak_start: '', + peak_end: '', + peak_rate_multiplier: 1, + is_exclusive: false, + }, + ], + supported_models: [{ name: 'claude-test', platform: 'anthropic', pricing: null }], + }, + ], + }, +] + +const baseProps = { + columns: { + name: 'Channel', + description: 'Description', + platform: 'Platform', + groups: 'Groups and rates', + supportedModels: 'Models and pricing', + }, + rows, + loading: false, + pricingKeyPrefix: 'availableChannels.pricing', + noPricingLabel: 'No pricing', + noModelsLabel: 'No models', + emptyLabel: 'No channels', + userGroupRates: { 1: 0.8 }, +} + +function mountTable(props = {}) { + return mount(AvailableChannelsTable, { + props: { ...baseProps, ...props }, + global: { + plugins: [createPinia()], + stubs: { + Icon: { props: ['name'], template: '' }, + PlatformIcon: { template: '' }, + GroupBadge: { + props: ['name', 'rateMultiplier', 'userRateMultiplier'], + template: + '{{ name }}:{{ rateMultiplier }}:{{ userRateMultiplier }}', + }, + SupportedModelChip: { + props: ['model', 'noPricingLabel'], + template: '{{ model.name }}:{{ noPricingLabel }}', + }, + }, + }, + }) +} + +describe('AvailableChannelsTable responsive surfaces', () => { + it('keeps the five-column table as the desktop-only surface', () => { + const wrapper = mountTable() + const desktop = wrapper.get('[data-testid="desktop-channels"]') + + // TablePageLayout has a more-specific `display: table` rule for descendants, + // so these display utilities must remain important at both breakpoints. + expect(desktop.classes()).toContain('!hidden') + expect(desktop.classes()).toContain('lg:!table') + expect(desktop.findAll('thead th')).toHaveLength(5) + expect(desktop.text()).toContain('Primary channel') + expect(desktop.text()).toContain('Fast and reliable access') + expect(desktop.findAll('[data-group-badge]')).toHaveLength(2) + expect(desktop.get('[data-model-chip]').text()).toContain('claude-test:No pricing') + }) + + it('renders a mobile-only readable surface with groups, rates, peaks, and model pricing chips', () => { + const wrapper = mountTable() + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.classes()).toContain('lg:hidden') + expect(mobile.classes()).toContain('overflow-x-hidden') + expect(mobile.text()).toContain('Primary channel') + expect(mobile.text()).toContain('Fast and reliable access') + expect(mobile.text()).toContain('Groups and rates') + expect(mobile.text()).toContain('Models and pricing') + expect(mobile.text()).toContain('availableChannels.exclusive') + expect(mobile.text()).toContain('availableChannels.public') + expect(mobile.get('[data-group-badge]').text()).toBe('Exclusive Pro:1.2:0.8') + expect(mobile.findAll('[data-group-badge]')).toHaveLength(2) + expect(mobile.get('[data-icon="clock"]')).toBeTruthy() + expect(mobile.text()).toContain('08:00') + expect(mobile.text()).toContain('10:00') + expect(mobile.text()).toContain('×1.5') + expect(mobile.get('[data-model-chip]').text()).toBe('claude-test:No pricing') + expect(mobile.findAll('.max-w-full')).not.toHaveLength(0) + }) + + it('keeps the mobile placeholders when a platform has no groups or models', () => { + const wrapper = mountTable({ + rows: [ + { + name: 'Fallback channel', + description: '', + platforms: [{ platform: 'openai', groups: [], supported_models: [] }], + }, + ], + }) + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.text()).toContain('Fallback channel') + expect(mobile.text()).toContain('openai') + expect(mobile.text()).toContain('No models') + expect(mobile.findAll('dd')[0].text()).toBe('-') + }) + + it('provides loading and empty states on both responsive surfaces', async () => { + const wrapper = mountTable({ loading: true, rows: [] }) + + expect(wrapper.get('[data-testid="desktop-channels"] [data-icon="refresh"]')).toBeTruthy() + expect(wrapper.get('[data-testid="mobile-loading"] [data-icon="refresh"]')).toBeTruthy() + + await wrapper.setProps({ loading: false }) + + expect(wrapper.get('[data-testid="desktop-channels"]').text()).toContain('No channels') + expect(wrapper.get('[data-testid="mobile-empty"]').text()).toContain('No channels') + }) +}) diff --git a/frontend/src/components/user/monitor/MonitorTimeline.vue b/frontend/src/components/user/monitor/MonitorTimeline.vue index 2445bc519..548e2c084 100644 --- a/frontend/src/components/user/monitor/MonitorTimeline.vue +++ b/frontend/src/components/user/monitor/MonitorTimeline.vue @@ -17,7 +17,7 @@
+ +export interface DailyPaymentStats { + date: string + amount: CurrencyAmounts + count: number +} + +export interface PaymentMethodStats { + type: string + amount: CurrencyAmounts + count: number +} + +export interface TopUserPaymentStats { + user_id: number + email: string + amount: number +} + export interface DashboardStats { - today_amount: number - total_amount: number + today_amount: CurrencyAmounts + total_amount: CurrencyAmounts today_count: number total_count: number - avg_amount: number - daily_series: { date: string; amount: number; count: number }[] - payment_methods: { type: string; amount: number; count: number }[] - top_users: { user_id: number; email: string; amount: number }[] + avg_amount: CurrencyAmounts + daily_series: DailyPaymentStats[] + payment_methods: PaymentMethodStats[] + top_users: Record } diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 260628fd0..54375581b 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3886,6 +3886,9 @@ class="input" placeholder="gpt-5" /> +

+ {{ t("admin.groups.compositeRoutes.upstreamModelHint") }} +

diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 8930cf635..2d64c92f0 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -335,6 +335,26 @@ const applyRouteQueryFilters = () => { granularity.value = getGranularityForRange(startDate.value, endDate.value) } +const loadRouteUserFilterLabel = async () => { + const requestedUserId = filters.value.user_id + if (!requestedUserId) return + const userSearchRevision = usageFiltersRef.value?.getUserSearchRevision?.() + + const routeUserFilterIsCurrent = () => ( + filters.value.user_id === requestedUserId + && usageFiltersRef.value?.getUserSearchRevision?.() === userSearchRevision + ) + + try { + const user = await adminAPI.users.getById(requestedUserId, true) + if (!routeUserFilterIsCurrent()) return + usageFiltersRef.value?.setUserKeyword?.(user.email || String(requestedUserId)) + } catch { + if (!routeUserFilterIsCurrent()) return + usageFiltersRef.value?.setUserKeyword?.(String(requestedUserId)) + } +} + const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => { startDate.value = range.startDate endDate.value = range.endDate @@ -822,6 +842,7 @@ const handleColumnClickOutside = (event: MouseEvent) => { onMounted(() => { applyRouteQueryFilters() + void loadRouteUserFilterLabel() loadLogs() loadStats() loadModelStats(modelDistributionSource.value, true) diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 6afeebe93..e0fc24b12 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, ref } from 'vue' import UsageView from '../UsageView.vue' -const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => { +const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => { vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn(), @@ -17,6 +18,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = getById: vi.fn(), getModelStats: vi.fn(), listErrorLogs: vi.fn(), + routeQuery: {} as Record, } }) @@ -85,12 +87,28 @@ vi.mock('vue-i18n', async () => { vi.mock('vue-router', () => ({ useRoute: () => ({ - query: {} + query: routeQuery }) })) const AppLayoutStub = { template: '
' } -const UsageFiltersStub = { template: '
' } +const UsageFiltersStub = defineComponent({ + setup(_, { expose }) { + const userKeyword = ref('') + let userSearchRevision = 0 + const setUserKeyword = (email: string) => { + userSearchRevision += 1 + userKeyword.value = email + } + expose({ + getUserSearchRevision: () => userSearchRevision, + setUserKeyword, + simulateUserInput: setUserKeyword, + }) + return { userKeyword } + }, + template: '
{{ userKeyword }}
', +}) const UsageTableStub = { emits: ['userClick'], template: '
', @@ -120,6 +138,114 @@ const GroupDistributionChartStub = { `, } +const mountRouteFilteredUsageView = () => mount(UsageView, { + global: { stubs: { + AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, + UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, + UserBalanceHistoryModal: true, Pagination: true, Select: true, + DateRangePicker: true, Icon: true, TokenUsageTrend: true, + ModelDistributionChart: true, GroupDistributionChart: true, + EndpointDistributionChart: true, UserTokenRanking: true, + } }, +}) + +describe('admin UsageView route filters', () => { + beforeEach(() => { + vi.useFakeTimers() + Object.keys(routeQuery).forEach((key) => delete routeQuery[key]) + list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 }) + getStats.mockReset().mockResolvedValue({ + total_requests: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_tokens: 0, + total_tokens: 0, + total_cost: 0, + total_actual_cost: 0, + average_duration_ms: 0, + }) + getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] }) + getModelStats.mockReset().mockResolvedValue({ models: [] }) + getById.mockReset() + }) + + afterEach(() => { + Object.keys(routeQuery).forEach((key) => delete routeQuery[key]) + vi.useRealTimers() + }) + + it('shows the routed user while applying user_id to usage requests', async () => { + routeQuery.user_id = '42' + getById.mockResolvedValue({ id: 42, email: 'route-user@test.com' }) + + const wrapper = mountRouteFilteredUsageView() + await flushPromises() + + expect(getById).toHaveBeenCalledWith(42, true) + expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 42 }), expect.anything()) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('route-user@test.com') + }) + + it('does not apply a stale routed user label after user_id changes', async () => { + routeQuery.user_id = '42' + let resolveLookup!: (user: { id: number; email: string }) => void + getById.mockReturnValue(new Promise((resolve) => { resolveLookup = resolve })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.vm as any).filters.user_id = 84 + ;(wrapper.findComponent(UsageFiltersStub).vm as any).setUserKeyword('current-user@test.com') + + resolveLookup({ id: 42, email: 'stale-user@test.com' }) + await flushPromises() + + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('current-user@test.com') + }) + + it('does not overwrite newer user input when the routed user lookup succeeds', async () => { + routeQuery.user_id = '42' + let resolveLookup!: (user: { id: number; email: string }) => void + getById.mockReturnValue(new Promise((resolve) => { resolveLookup = resolve })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.findComponent(UsageFiltersStub).vm as any).simulateUserInput('new-search@test.com') + + resolveLookup({ id: 42, email: 'route-user@test.com' }) + await flushPromises() + + expect((wrapper.vm as any).filters.user_id).toBe(42) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('new-search@test.com') + }) + + it('does not overwrite newer user input when the routed user lookup fails', async () => { + routeQuery.user_id = '42' + let rejectLookup!: (error: Error) => void + getById.mockReturnValue(new Promise((_, reject) => { rejectLookup = reject })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.findComponent(UsageFiltersStub).vm as any).simulateUserInput('new-search@test.com') + + rejectLookup(new Error('lookup failed')) + await flushPromises() + + expect((wrapper.vm as any).filters.user_id).toBe(42) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('new-search@test.com') + }) + + it('shows the routed user ID when its label lookup fails', async () => { + routeQuery.user_id = '42' + getById.mockRejectedValue(new Error('lookup failed')) + + const wrapper = mountRouteFilteredUsageView() + await flushPromises() + + expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 42 }), expect.anything()) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('42') + }) +}) + describe('admin UsageView distribution metric toggles', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue index 5a80db44f..70fe95962 100644 --- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue @@ -41,8 +41,8 @@ {{ t('payment.methods.' + method.type, method.type) }}
-
- ¥{{ method.amount.toFixed(2) }} +
+ {{ formatMoney(currency, amount) }} ({{ method.count }})
@@ -50,14 +50,17 @@

{{ t('payment.admin.topUsers') }}

-
{{ t('payment.admin.noData') }}
+
{{ t('payment.admin.noData') }}
-
-
- {{ idx + 1 }} - {{ user.email }} +
+

{{ currency }}

+
+
+ {{ idx + 1 }} + {{ user.email }} +
+ {{ formatMoney(currency, user.amount) }}
- ¥{{ user.amount.toFixed(2) }}
@@ -73,7 +76,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminPaymentAPI } from '@/api/admin/payment' import { extractI18nErrorMessage } from '@/utils/apiError' -import type { DashboardStats } from '@/types/payment' +import type { CurrencyAmounts, DashboardStats, TopUserPaymentStats } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import Icon from '@/components/icons/Icon.vue' @@ -104,6 +107,22 @@ function rankClass(idx: number): string { return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400' } +function sortedAmounts(amounts: CurrencyAmounts): [string, number][] { + return Object.entries(amounts).sort(([left], [right]) => left.localeCompare(right)) +} + +function sortedTopUsers(usersByCurrency: Record): [string, TopUserPaymentStats[]][] { + return Object.entries(usersByCurrency).sort(([left], [right]) => left.localeCompare(right)) +} + +function hasTopUsers(usersByCurrency: Record): boolean { + return Object.values(usersByCurrency).some(users => users.length > 0) +} + +function formatMoney(currency: string, amount: number): string { + return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount) +} + async function loadDashboard() { loading.value = true try { diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index b4a7a6009..4b8977914 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -134,6 +134,27 @@
+ +
+ +
+
+ +
+ +
+
+
-
+
(true) const emailVerifyEnabled = ref(false) const promoCodeEnabled = ref(true) const invitationCodeEnabled = ref(false) +const affiliateEnabled = ref(false) const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const siteName = ref('Sub2API') @@ -459,6 +481,7 @@ onMounted(async () => { emailVerifyEnabled.value = settings.email_verify_enabled promoCodeEnabled.value = settings.promo_code_enabled invitationCodeEnabled.value = settings.invitation_code_enabled + affiliateEnabled.value = settings.affiliate_enabled turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' siteName.value = settings.site_name || 'Sub2API' diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts new file mode 100644 index 000000000..20f63018a --- /dev/null +++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts @@ -0,0 +1,112 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import RegisterView from '@/views/auth/RegisterView.vue' + +const { getPublicSettingsMock } = vi.hoisted(() => ({ + getPublicSettingsMock: vi.fn() +})) + +const publicSettings = { + registration_enabled: true, + email_verify_enabled: false, + promo_code_enabled: false, + invitation_code_enabled: false, + affiliate_enabled: true, + turnstile_enabled: true, + turnstile_site_key: 'site-key', + site_name: 'Sub2API', + registration_email_suffix_whitelist: [], + linuxdo_oauth_enabled: false, + wechat_oauth_enabled: false, + oidc_oauth_enabled: false, + github_oauth_enabled: false, + google_oauth_enabled: false +} + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ query: {} }) +})) + +vi.mock('vue-i18n', () => ({ + createI18n: () => ({ + global: { + t: (key: string) => key + } + }), + useI18n: () => ({ + t: (key: string) => key, + locale: { value: 'en' } + }) +})) + +vi.mock('@/stores', () => ({ + useAuthStore: () => ({ register: vi.fn() }), + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn() + }) +})) + +vi.mock('@/api/auth', async () => { + const actual = await vi.importActual('@/api/auth') + return { + ...actual, + getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args) + } +}) + +function mountRegister() { + return mount(RegisterView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { template: '
' }, + LoginAgreementPrompt: true, + EmailOAuthButtons: true, + LinuxDoOAuthSection: true, + WechatOAuthSection: true, + OidcOAuthSection: true, + RouterLink: true, + transition: false + } + } + }) +} + +describe('RegisterView invitation layout', () => { + beforeEach(() => { + getPublicSettingsMock.mockReset() + getPublicSettingsMock.mockResolvedValue(publicSettings) + }) + + it('keeps the optional affiliate invitation field before Turnstile', async () => { + const wrapper = mountRegister() + await flushPromises() + + const invitationField = wrapper.get('[data-testid="affiliate-invitation-field"]') + const turnstile = wrapper.get('[data-testid="registration-turnstile"]') + + expect(invitationField.get('input').attributes('id')).toBe('affiliate_code') + expect(invitationField.text()).toContain('common.optional') + expect( + invitationField.element.compareDocumentPosition(turnstile.element) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('uses the mandatory invitation field without duplicating the affiliate field', async () => { + getPublicSettingsMock.mockResolvedValueOnce({ + ...publicSettings, + invitation_code_enabled: true + }) + + const wrapper = mountRegister() + await flushPromises() + + expect(wrapper.find('[data-testid="affiliate-invitation-field"]').exists()).toBe(false) + expect(wrapper.get('#invitation_code').exists()).toBe(true) + }) +})