Merge branch 'Wei-Shaw:main' into fix/antigravity-openai-compat
This commit is contained in:
101 files changed
+3615
-430
No files matched your search
@@ -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
|
||||
|
||||
@@ -102,18 +102,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>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. <a href="https://apikl.ai">Register now</a> for a 1:1 top-up bonus — double your balance!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> 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 <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
|
||||
@@ -135,12 +123,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 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.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> 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.<br>
|
||||
|
||||
@@ -105,18 +105,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.ai">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
|
||||
@@ -138,12 +126,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
|
||||
|
||||
@@ -104,18 +104,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.ai">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
|
||||
@@ -133,12 +121,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
|
||||
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
+9
-8
@@ -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
|
||||
|
||||
+20
-44
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 时使用。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+104
@@ -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 <limit> 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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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-* 型号不回退,避免误计价。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<<uint(attempt-1))
|
||||
if delay > geminiRetryMaxDelay {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+8
-1
@@ -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*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.today_amount) }}</p>
|
||||
<p v-for="[currency, amount] in sortedAmounts(stats.today_amount)" :key="currency" class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatMoney(currency, amount) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.today_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
@@ -24,7 +26,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.totalRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.total_amount) }}</p>
|
||||
<p v-for="[currency, amount] in sortedAmounts(stats.total_amount)" :key="currency" class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatMoney(currency, amount) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.total_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
@@ -53,7 +57,9 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.avgAmount') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.avg_amount) }}</p>
|
||||
<p v-for="[currency, amount] in sortedAmounts(stats.avg_amount)" :key="currency" class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatMoney(currency, amount) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +69,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
import type { CurrencyAmounts, DashboardStats } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -71,7 +77,11 @@ defineProps<{
|
||||
stats: DashboardStats
|
||||
}>()
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
function sortedAmounts(amounts: CurrencyAmounts): [string, number][] {
|
||||
return Object.entries(amounts).sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function formatMoney(currency: string, amount: number): string {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
</script>
|
||||
@@ -18,20 +18,23 @@
|
||||
{{ t('payment.methods.' + method.type, method.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${{ method.amount.toFixed(2) }}
|
||||
<div class="space-y-1 text-right">
|
||||
<span v-for="[currency, amount] in sortedAmounts(method.amount)" :key="currency" class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatMoney(currency, amount) }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ method.count }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
|
||||
:style="{ width: barWidth(method.amount) + '%' }"
|
||||
></div>
|
||||
<div v-for="[currency, amount] in sortedAmounts(method.amount)" :key="currency" class="flex items-center gap-2">
|
||||
<span class="w-10 text-xs text-gray-500 dark:text-gray-400">{{ currency }}</span>
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
|
||||
:style="{ width: barWidth(currency, amount) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,11 +44,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { CurrencyAmounts, PaymentMethodStats } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
methods: { type: string; amount: number; count: number }[]
|
||||
methods: PaymentMethodStats[]
|
||||
}>()
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
@@ -64,12 +68,24 @@ const barColorMap: Record<string, string> = {
|
||||
stripe: 'bg-purple-500',
|
||||
}
|
||||
|
||||
const maxAmount = computed(() => {
|
||||
if (!props.methods?.length) return 1
|
||||
return Math.max(...props.methods.map(m => m.amount), 1)
|
||||
const maxAmounts = computed<CurrencyAmounts>(() => {
|
||||
return props.methods.reduce<CurrencyAmounts>((maximums, method) => {
|
||||
for (const [currency, amount] of Object.entries(method.amount)) {
|
||||
maximums[currency] = Math.max(maximums[currency] || 0, amount)
|
||||
}
|
||||
return maximums
|
||||
}, {})
|
||||
})
|
||||
|
||||
function barWidth(amount: number): number {
|
||||
return Math.min((amount / maxAmount.value) * 100, 100)
|
||||
function sortedAmounts(amounts: CurrencyAmounts): [string, number][] {
|
||||
return Object.entries(amounts).sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function barWidth(currency: string, amount: number): number {
|
||||
return Math.min((amount / (maxAmounts.value[currency] || 1)) * 100, 100)
|
||||
}
|
||||
|
||||
function formatMoney(currency: string, amount: number): string {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
</script>
|
||||
@@ -4,31 +4,34 @@
|
||||
{{ t('payment.admin.topUsers') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!users?.length"
|
||||
v-if="!hasUsers(props.users)"
|
||||
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('payment.admin.noData') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(user, idx) in users"
|
||||
:key="user.user_id"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
:class="[
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
rankClass(idx),
|
||||
]"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
<div v-for="[currency, currencyUsers] in sortedUsers(props.users)" :key="currency" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ currency }}</p>
|
||||
<div
|
||||
v-for="(user, idx) in currencyUsers"
|
||||
:key="user.user_id"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
:class="[
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
rankClass(idx),
|
||||
]"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatMoney(currency, user.amount) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
${{ user.amount.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,11 +39,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { TopUserPaymentStats } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
users: { user_id: number; email: string; amount: number }[]
|
||||
const props = defineProps<{
|
||||
users: Record<string, TopUserPaymentStats[]>
|
||||
}>()
|
||||
|
||||
function rankClass(idx: number): string {
|
||||
@@ -49,4 +53,16 @@ function rankClass(idx: number): string {
|
||||
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
|
||||
}
|
||||
|
||||
function hasUsers(usersByCurrency: Record<string, TopUserPaymentStats[]>): boolean {
|
||||
return Object.values(usersByCurrency).some(users => users.length > 0)
|
||||
}
|
||||
|
||||
function sortedUsers(usersByCurrency: Record<string, TopUserPaymentStats[]>): [string, TopUserPaymentStats[]][] {
|
||||
return Object.entries(usersByCurrency).sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function formatMoney(currency: string, amount: number): string {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
</script>
|
||||
@@ -523,5 +523,7 @@ const setUserKeyword = (email: string) => {
|
||||
showUserDropdown.value = false
|
||||
}
|
||||
|
||||
defineExpose({ setUserKeyword })
|
||||
const getUserSearchRevision = () => userSearchSequence
|
||||
|
||||
defineExpose({ getUserSearchRevision, setUserKeyword })
|
||||
</script>
|
||||
@@ -2,7 +2,10 @@
|
||||
<!-- .table-wrapper 是 TablePageLayout 滚动链的挂载点:外层 .table-scroll-container
|
||||
负责卡片外观并 overflow-hidden,本层接收 overflow-y-auto 才能在内容超高时滚动。 -->
|
||||
<div class="table-wrapper">
|
||||
<table class="w-full table-fixed border-collapse text-sm">
|
||||
<table
|
||||
data-testid="desktop-channels"
|
||||
class="!hidden w-full table-fixed border-collapse text-sm lg:!table"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100 bg-gray-50/50 text-xs font-medium uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:bg-dark-800/50 dark:text-gray-400">
|
||||
<th class="w-[180px] px-4 py-3 text-center">{{ columns.name }}</th>
|
||||
@@ -168,6 +171,151 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div data-testid="mobile-channels" class="w-full min-w-0 overflow-x-hidden lg:hidden">
|
||||
<div v-if="loading" data-testid="mobile-loading" class="py-10 text-center">
|
||||
<Icon name="refresh" size="lg" class="inline-block animate-spin text-gray-400" />
|
||||
</div>
|
||||
<div v-else-if="rows.length === 0" data-testid="mobile-empty" class="py-12 text-center">
|
||||
<Icon name="inbox" size="xl" class="mx-auto mb-3 h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ emptyLabel }}</p>
|
||||
</div>
|
||||
<section
|
||||
v-else
|
||||
v-for="(channel, chIdx) in rows"
|
||||
:key="`mobile-${channel.name}-${chIdx}`"
|
||||
class="border-b-2 border-gray-200 px-4 py-4 last:border-b-0 dark:border-dark-600"
|
||||
>
|
||||
<header class="mb-3 min-w-0">
|
||||
<h3 class="break-words text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ channel.name }}
|
||||
</h3>
|
||||
<p class="mt-1 break-words text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ channel.description || '-' }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="divide-y divide-gray-100 dark:divide-dark-700/60">
|
||||
<div
|
||||
v-for="section in channel.platforms"
|
||||
:key="`mobile-${channel.name}-${section.platform}`"
|
||||
class="min-w-0 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium uppercase',
|
||||
platformBadgeClass(section.platform),
|
||||
]"
|
||||
>
|
||||
<PlatformIcon :platform="section.platform as GroupPlatform" size="xs" />
|
||||
{{ section.platform }}
|
||||
</span>
|
||||
|
||||
<dl class="mt-3 space-y-3">
|
||||
<div class="min-w-0">
|
||||
<dt class="mb-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ columns.groups }}
|
||||
</dt>
|
||||
<dd class="flex min-w-0 flex-col gap-2">
|
||||
<div
|
||||
v-if="exclusiveGroups(section).length > 0"
|
||||
class="flex min-w-0 flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-purple-600 dark:text-purple-400"
|
||||
:title="t('availableChannels.exclusiveTooltip')"
|
||||
>
|
||||
<Icon name="shield" size="xs" class="h-3 w-3" />
|
||||
{{ t('availableChannels.exclusive') }}
|
||||
</span>
|
||||
<div
|
||||
v-for="g in exclusiveGroups(section)"
|
||||
:key="`mobile-ex-${g.id}`"
|
||||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1"
|
||||
>
|
||||
<GroupBadge
|
||||
class="max-w-full"
|
||||
:name="g.name"
|
||||
:platform="g.platform as GroupPlatform"
|
||||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||||
:rate-multiplier="g.rate_multiplier"
|
||||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||||
always-show-rate
|
||||
/>
|
||||
<span
|
||||
v-if="hasPeakRate(g)"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
:title="peakRateTitle(g)"
|
||||
>
|
||||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||||
{{ peakRateLabel(g) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="publicGroups(section).length > 0"
|
||||
class="flex min-w-0 flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5 text-[10px] font-medium uppercase text-gray-500 dark:text-gray-400"
|
||||
:title="t('availableChannels.publicTooltip')"
|
||||
>
|
||||
<Icon name="globe" size="xs" class="h-3 w-3" />
|
||||
{{ t('availableChannels.public') }}
|
||||
</span>
|
||||
<div
|
||||
v-for="g in publicGroups(section)"
|
||||
:key="`mobile-pub-${g.id}`"
|
||||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1"
|
||||
>
|
||||
<GroupBadge
|
||||
class="max-w-full"
|
||||
:name="g.name"
|
||||
:platform="g.platform as GroupPlatform"
|
||||
:subscription-type="(g.subscription_type || 'standard') as SubscriptionType"
|
||||
:rate-multiplier="g.rate_multiplier"
|
||||
:user-rate-multiplier="userGroupRates[g.id] ?? null"
|
||||
always-show-rate
|
||||
/>
|
||||
<span
|
||||
v-if="hasPeakRate(g)"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
:title="peakRateTitle(g)"
|
||||
>
|
||||
<Icon name="clock" size="xs" class="h-3 w-3" />
|
||||
{{ peakRateLabel(g) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="section.groups.length === 0" class="text-xs text-gray-400">-</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<dt class="mb-1.5 text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ columns.supportedModels }}
|
||||
</dt>
|
||||
<dd class="flex min-w-0 flex-wrap gap-1">
|
||||
<SupportedModelChip
|
||||
v-for="m in section.supported_models"
|
||||
:key="`mobile-${section.platform}-${m.name}`"
|
||||
class="max-w-full [&>span]:max-w-full [&>span]:truncate"
|
||||
:model="m"
|
||||
:pricing-key-prefix="pricingKeyPrefix"
|
||||
:no-pricing-label="noPricingLabel"
|
||||
:show-platform="false"
|
||||
:platform-hint="section.platform"
|
||||
/>
|
||||
<span v-if="section.supported_models.length === 0" class="text-xs text-gray-400">
|
||||
{{ noModelsLabel }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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<typeof import('vue-i18n')>('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(/<div class="card overflow-hidden">/)
|
||||
})
|
||||
})
|
||||
|
||||
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: '<i :data-icon="name" />' },
|
||||
PlatformIcon: { template: '<i data-platform-icon />' },
|
||||
GroupBadge: {
|
||||
props: ['name', 'rateMultiplier', 'userRateMultiplier'],
|
||||
template:
|
||||
'<span data-group-badge>{{ name }}:{{ rateMultiplier }}:{{ userRateMultiplier }}</span>',
|
||||
},
|
||||
SupportedModelChip: {
|
||||
props: ['model', 'noPricingLabel'],
|
||||
template: '<span data-model-chip>{{ model.name }}:{{ noPricingLabel }}</span>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@
|
||||
<div
|
||||
v-for="(bar, idx) in displayBars"
|
||||
:key="idx"
|
||||
class="flex-1 min-w-[3px] rounded-sm"
|
||||
class="flex-1 min-w-0 rounded-sm"
|
||||
:class="bar.colorClass"
|
||||
:style="{ height: bar.heightPct + '%' }"
|
||||
:title="bar.title"
|
||||
|
||||
@@ -1031,6 +1031,7 @@ export default {
|
||||
endpoint: 'Endpoint',
|
||||
targetPlatform: 'Target Platform',
|
||||
upstreamModel: 'Upstream Model',
|
||||
upstreamModelHint: 'Leave empty to pass the original requested model through: under prefix match each matched model forwards verbatim (e.g. deepseek-v4-flash and deepseek-v4-pro each forwarded as-is); set a value to forward every matched request to that fixed model.',
|
||||
notes: 'Notes',
|
||||
enabled: 'Enabled',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -1028,6 +1028,7 @@ export default {
|
||||
endpoint: '端点',
|
||||
targetPlatform: '目标平台',
|
||||
upstreamModel: '上游模型',
|
||||
upstreamModelHint: '留空表示透传原始请求模型:前缀匹配下每个命中模型各自原样转发(如 deepseek-v4-flash、deepseek-v4-pro 分别转发);填写则所有命中请求都固定转发该模型。',
|
||||
notes: '备注',
|
||||
enabled: '启用',
|
||||
preview: '预览',
|
||||
|
||||
@@ -222,13 +222,33 @@ export interface CreateOrderResult {
|
||||
jsapi_payload?: WechatJSAPIPayload
|
||||
}
|
||||
|
||||
export type CurrencyAmounts = Record<string, number>
|
||||
|
||||
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<string, TopUserPaymentStats[]>
|
||||
}
|
||||
@@ -3886,6 +3886,9 @@
|
||||
class="input"
|
||||
placeholder="gpt-5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.compositeRoutes.upstreamModelHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string>,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -85,12 +87,28 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
query: routeQuery
|
||||
})
|
||||
}))
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
|
||||
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: '<div><span data-test="user-filter-label">{{ userKeyword }}</span><slot name="after-reset" /></div>',
|
||||
})
|
||||
const UsageTableStub = {
|
||||
emits: ['userClick'],
|
||||
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
|
||||
@@ -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()
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
<span :class="['inline-block h-3 w-3 rounded-full', methodColor(method.type)]"></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ method.amount.toFixed(2) }}</span>
|
||||
<div class="space-y-1 text-right">
|
||||
<span v-for="[currency, amount] in sortedAmounts(method.amount)" :key="currency" class="block text-sm font-medium text-gray-900 dark:text-white">{{ formatMoney(currency, amount) }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,14 +50,17 @@
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('payment.admin.topUsers') }}</h3>
|
||||
<div v-if="!stats.top_users?.length" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
||||
<div v-if="!hasTopUsers(stats.top_users)" class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.admin.noData') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(user, idx) in stats.top_users" :key="user.user_id" class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
<div v-for="[currency, users] in sortedTopUsers(stats.top_users)" :key="currency" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ currency }}</p>
|
||||
<div v-for="(user, idx) in users" :key="user.user_id" class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ formatMoney(currency, user.amount) }}</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">¥{{ user.amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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[]>): [string, TopUserPaymentStats[]][] {
|
||||
return Object.entries(usersByCurrency).sort(([left], [right]) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function hasTopUsers(usersByCurrency: Record<string, TopUserPaymentStats[]>): 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 {
|
||||
|
||||
@@ -134,6 +134,27 @@
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Affiliate Invitation Code Input (Optional) -->
|
||||
<div v-else-if="affiliateEnabled" data-testid="affiliate-invitation-field">
|
||||
<label for="affiliate_code" class="input-label">
|
||||
{{ t('auth.invitationCodeLabel') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="key" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="affiliate_code"
|
||||
v-model="formData.aff_code"
|
||||
type="text"
|
||||
:disabled="registrationActionDisabled"
|
||||
class="input pl-11"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promo Code Input (Optional) -->
|
||||
<div v-if="promoCodeEnabled">
|
||||
<label for="promo_code" class="input-label">
|
||||
@@ -183,7 +204,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey" data-testid="registration-turnstile">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@@ -351,6 +372,7 @@ const registrationEnabled = ref<boolean>(true)
|
||||
const emailVerifyEnabled = ref<boolean>(false)
|
||||
const promoCodeEnabled = ref<boolean>(true)
|
||||
const invitationCodeEnabled = ref<boolean>(false)
|
||||
const affiliateEnabled = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('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'
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user