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