feat: 余额预扣 reserve/capture、冷却兜底重试、Prometheus /metrics

- 预扣:每请求按 body 估算成本上界计入冻结额(1e-8 USD 原子计数),402 判定
  改为「余额 - 在途冻结 ≤ 0」,并发的长流式请求不再互相透支;OT_PROXY_RESERVE_MAX_TOKENS
  控制估算基数(默认 4096,0=关闭),handler defer 释放,失败/中断/panic 均覆盖
- 调度:全部候选都在运行期冷却时,剩余冷却 ≤ OT_PROXY_COOLDOWN_GRACE 的最早恢复渠道
  兜底重试一次而不是 503(429 Retry-After 语义);DB 层不可用的渠道不参与兜底
- 指标:openteam_proxy_requests_total / request_duration / channel_failures /
  usage_queue_depth / frozen_balance,/metrics 默认关闭(OT_METRICS_ENABLED 开启)
This commit is contained in:
Sakurasan committed 2026-11-09 01:04:40 +08:00
1 parent b2e500ff51
commit 0e10f0bbde
14 files changed
+493 -36

No files matched your search

+16 -9
View File
@@ -1,26 +1,29 @@
module github.com/openteam/server
go 1.26
go 1.26.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/prometheus/client_golang v1.25.0
github.com/shopspring/decimal v1.6.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/tiktoken-go/tokenizer v0.8.1
golang.org/x/crypto v0.55.0
golang.org/x/term v0.45.0
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -51,8 +54,12 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -67,13 +74,13 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+40 -21
View File
@@ -1,9 +1,13 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -77,12 +81,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -94,12 +102,21 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
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.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -137,8 +154,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
@@ -153,32 +170,34 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
+6
View File
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/proxy"
)
@@ -149,5 +150,10 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
r.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Prometheus 指标端点:默认关闭——模型/渠道名与流量规模本身就是信息,
// 公网部署要开时建议在反代层做访问控制。
if a.Cfg.MetricsEnabled {
r.GET("/metrics", gin.WrapH(metrics.Handler()))
}
return r
}
+39
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
@@ -142,6 +143,43 @@ func (s *Service) Candidates(model string) []Candidate {
return out
}
// EarliestRecovery 在该模型的候选渠道里找运行期冷却最早结束的一个。
// 全部候选都在冷却时的兜底(配合调用方的冷却宽限配置):与其立刻 503,
// 不如试试最先恢复的那条。DB 层不可用(停用/健康探测失败)的渠道不参与——
// 运行期冷却到期即可恢复,健康状态是另一回事,不要在这里越权放行。
func (s *Service) EarliestRecovery(model string) (Candidate, time.Duration, bool) {
rows, bound := s.catalog(model)
var best Candidate
var bestRem time.Duration
found := false
for i := range rows {
ch := rows[i].ch
if !ch.Enabled || ch.HealthStatus != store.ChannelHealthHealthy {
continue
}
s.mu.Lock()
st := s.runt[ch.ID]
cooling := st != nil && !st.until.IsZero() && time.Now().Before(st.until)
var rem time.Duration
if cooling {
rem = time.Until(st.until)
}
s.mu.Unlock()
if !cooling {
continue
}
if !found || rem < bestRem {
cand := Candidate{Channel: &ch}
if bound {
cand.UpstreamModel = rows[i].upstreamModel
cand.Weight = rows[i].weight
}
best, bestRem, found = cand, rem, true
}
}
return best, bestRem, found
}
// catalog 取该模型的目录行(缓存命中返回副本,避免调用方改写缓存内容)。
// bound 表示这些行来自「该模型的渠道绑定」而不是全局渠道池。
func (s *Service) catalog(model string) (rows []catRow, bound bool) {
@@ -370,6 +408,7 @@ func (s *Service) UpstreamKey(ch *store.Channel) (string, error) {
// maxCooldown——不能让一个写错的重试头把渠道钉死几小时;比退避值短的 Retry-After
// 不缩短冷却,否则 1s 就把坏渠道放回轮转。
func (s *Service) ReportFailure(ch *store.Channel, kind FailKind, retryAfter time.Duration) {
metrics.ChannelFailures.WithLabelValues(ch.Name, kind.String()).Inc()
s.mu.Lock()
st := s.runt[ch.ID]
if st == nil {
+2
View File
@@ -41,6 +41,8 @@ func serve() error {
MaxBodyMB: cfg.Proxy.MaxBodyMB,
FailoverBudget: cfg.Proxy.FailoverBudget,
FailoverSwitchCaps: cfg.Proxy.FailoverSwitchCaps,
CooldownGrace: cfg.Proxy.CooldownGrace,
ReserveMaxTokens: cfg.Proxy.ReserveMaxTokens,
StreamHeartbeat: cfg.Proxy.StreamHeartbeat,
FirstTokenWait: cfg.Proxy.FirstTokenWait,
AffinityTTL: cfg.Proxy.AffinityTTL,
+16 -1
View File
@@ -31,6 +31,9 @@ type Config struct {
// 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP,
// 认证限速依赖这个 IP;直连部署留空即可。
TrustedProxies []string
// MetricsEnabled 是否暴露 Prometheus /metrics 端点(会泄露模型/渠道名与流量,默认关)。
MetricsEnabled bool
}
// UsageConfig 用量数据的保留策略。
@@ -90,7 +93,13 @@ type ProxyConfig struct {
FailoverBudget time.Duration // 故障转移给"第二个及以后的渠道"留的时间预算,0=不限
// FailoverSwitchCaps 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限。
// 429/配额类失败瞬时返回,时间预算对它们形同虚设,按次数封顶才有意义。
FailoverSwitchCaps map[string]int
FailoverSwitchCaps map[string]int
// CooldownGrace 全部候选渠道都在运行期冷却时,最早恢复者剩余冷却 ≤ 此值就兜底
// 重试一次而不是 503(429 的 Retry-After 语义)。0=关闭。
CooldownGrace time.Duration
// ReserveMaxTokens 预扣(reserve/capture)估算的最大输出 token,0=关闭。
// 每请求按 body 估算成本上界计入冻结额,402 判定改为「余额 - 在途冻结 ≤ 0」。
ReserveMaxTokens int
StreamHeartbeat time.Duration // 流式空闲多久发一次 SSE 注释心跳,0=关闭
FirstTokenWait time.Duration // 流式放行前等内容的最长时间,0=不限;超时未出内容换下一个渠道
AffinityTTL time.Duration // 会话亲和 TTL,0=关闭;同一会话粘住上次应答的渠道以命中上游缓存
@@ -166,6 +175,9 @@ func Load() (*Config, error) {
v.SetDefault("proxy.failover_caps.server", 3)
v.SetDefault("proxy.failover_caps.rate", 2)
v.SetDefault("proxy.failover_caps.quota", 3)
v.SetDefault("proxy.cooldown_grace", "30s")
v.SetDefault("proxy.reserve_max_tokens", 4096)
v.SetDefault("metrics.enabled", false)
v.SetDefault("proxy.stream_heartbeat", "15s")
v.SetDefault("proxy.first_token_wait", "0s")
v.SetDefault("proxy.affinity_ttl", "5m")
@@ -226,6 +238,8 @@ func Load() (*Config, error) {
"rate": v.GetInt("proxy.failover_caps.rate"),
"quota": v.GetInt("proxy.failover_caps.quota"),
},
CooldownGrace: v.GetDuration("proxy.cooldown_grace"),
ReserveMaxTokens: v.GetInt("proxy.reserve_max_tokens"),
StreamHeartbeat: v.GetDuration("proxy.stream_heartbeat"),
FirstTokenWait: v.GetDuration("proxy.first_token_wait"),
AffinityTTL: v.GetDuration("proxy.affinity_ttl"),
@@ -253,6 +267,7 @@ func Load() (*Config, error) {
AdminPassword: v.GetString("admin_password"),
CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")),
TrustedProxies: splitList(v.GetString("server.trusted_proxies")),
MetricsEnabled: v.GetBool("metrics.enabled"),
}
if err := cfg.validate(); err != nil {
return nil, err
+62
View File
@@ -0,0 +1,62 @@
// Package metrics Prometheus 指标:进程内 registry + 文本端点(api 层按开关挂载 /metrics)。
//
// label 基数约束:channel/model 来自受管目录、status/kind 为有限枚举,可安全做 label;
// 请求级随机值(trace id、用户 id、错误详情)禁止入 label,否则基数爆炸拖垮采集端。
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var reg = prometheus.NewRegistry()
var (
// RequestsTotal 代理请求终结计数。channel 为 "none" 表示未打到任何渠道
// (无候选/满载等),model 为请求的模型名。
RequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "openteam_proxy_requests_total",
Help: "Proxied requests terminated, by channel/model/status.",
}, []string{"channel", "model", "status"})
// RequestDuration 请求从进入 handler 到终结的耗时(按渠道)。
RequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "openteam_proxy_request_duration_seconds",
Help: "Proxy request duration, by channel.",
Buckets: []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30, 60, 120, 300},
}, []string{"channel"})
// ChannelFailures 上游失败计数(按渠道与失败类型),与运行期冷却同源。
ChannelFailures = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "openteam_channel_failures_total",
Help: "Upstream failures, by channel/fail kind.",
}, []string{"channel", "kind"})
// RecorderQueueDepth 记账队列中待落库的记录数。持续增长说明 DB 写入跟不上。
RecorderQueueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "openteam_usage_queue_depth",
Help: "Usage records buffered, waiting to be flushed.",
})
// FrozenBalance 在途请求预扣(冻结)的金额合计(USD)。见 proxy 的 reserve/capture。
FrozenBalance = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "openteam_frozen_balance",
Help: "Balance reserved by in-flight requests (USD).",
})
)
func init() {
reg.MustRegister(
RequestsTotal, RequestDuration, ChannelFailures, RecorderQueueDepth, FrozenBalance,
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
}
// Handler /metrics 的 http.Handler(独立 registry,避免全局默认注册器的耦合)。
func Handler() http.Handler {
return promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
}
@@ -0,0 +1,58 @@
package metrics
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// /metrics 文本端点:注入计数/观测后可被抓取,指标名与 label 齐全。
func TestHandlerExposesInstruments(t *testing.T) {
RequestsTotal.WithLabelValues("c1", "m1", "success").Inc()
RequestsTotal.WithLabelValues("none", "m1", "error").Inc()
RequestDuration.WithLabelValues("c1").Observe(0.42)
ChannelFailures.WithLabelValues("c1", "rate_limited").Inc()
RecorderQueueDepth.Set(7)
FrozenBalance.Set(0.00246)
srv := httptest.NewServer(Handler())
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
buf := make([]byte, 1<<16)
n, _ := resp.Body.Read(buf)
body := string(buf[:n])
for _, want := range []string{
"openteam_proxy_requests_total{",
`channel="c1"`,
`status="success"`,
"openteam_proxy_request_duration_seconds",
"openteam_channel_failures_total",
"openteam_usage_queue_depth 7",
"openteam_frozen_balance 0.00246",
"go_goroutines", // Go 运行时指标一并暴露
} {
if !strings.Contains(body, want) {
t.Errorf("metrics 输出缺少 %q", want)
}
}
// 直方图观测应产生 bucket 样本
if !strings.Contains(body, `openteam_proxy_request_duration_seconds_bucket`) && n < len(buf) {
// 一次 Read 可能读不完,重读剩余部分再断言
rest := make([]byte, 1<<16)
n2, _ := resp.Body.Read(rest)
body += string(rest[:n2])
if !strings.Contains(body, "openteam_proxy_request_duration_seconds_bucket") {
t.Errorf("metrics 输出缺少直方图 bucket 样本")
}
}
_ = time.Second
}
+112 -1
View File
@@ -16,11 +16,14 @@ import (
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"sync/atomic"
)
const (
@@ -29,6 +32,11 @@ const (
CtxKeyID = "proxy_key_id"
CtxKeyModel = "proxy_key_models"
CtxTrace = "proxy_trace_id"
// CtxReserve 本请求的预扣(冻结)金额,单位 1e-8 USD(与 numeric(20,8) 对齐)。
CtxReserve = "proxy_reserve_1e8"
// CtxForceCooling 候选集来自「全冷却时最早恢复」的兜底,选路时放行冷却中的渠道。
CtxForceCooling = "proxy_force_cooling"
)
type Gateway struct {
@@ -49,6 +57,16 @@ type Gateway struct {
// 按失败类型限制换渠道次数(0=不限):429/配额类失败是瞬时返回,
// 时间预算对它们形同虚设,按次数封顶才有意义。
failoverCaps map[channel.FailKind]int
// 冷却兜底宽限:全部候选都在运行期冷却时,最早恢复的渠道剩余冷却 ≤ 此值
// 就带上它兜底重试一次(429 的 Retry-After 语义本就是"到点再试");0=关闭。
cooldownGrace time.Duration
// 预扣(reserve/capture):每请求按 body 估算成本上界计入冻结额,
// 402 判定改为「余额 - 在途冻结 ≤ 0」,并发的长流式请求不再能把余额拖到深度负值。
// reserveMaxTokens 是输出 token 估算基数(0=关闭预扣);冻结额按用户分桶存
// 1e-8 USD 整数(每用户一个 atomic.Int64),冻结/释放 O(1) 无锁竞争。
reserveMaxTokens int
frozen sync.Map // uint64(用户ID) → *atomic.Int64(1e-8 USD)
frozenTotal atomic.Int64 // 全局冻结合计(指标用)
// 流式空闲多久补一行 SSE 注释心跳(0=关闭)。反向代理的空闲超时会掐掉长思考的流。
streamHeartbeat time.Duration
// 流式放行前等多久内容(0=不限制)。超时未出内容取消本次尝试换下一个渠道。
@@ -226,6 +244,8 @@ type Options struct {
MaxBodyMB int // /v1 请求体上限(MB)
FailoverBudget time.Duration // 故障转移重试的时间预算
FailoverSwitchCaps map[string]int // 按失败类型(net|server|rate|quota)限制换渠道次数,0=不限
CooldownGrace time.Duration // 全冷却时最早恢复渠道的兜底宽限(0=关闭)
ReserveMaxTokens int // 预扣估算的最大输出 token(0=关闭预扣)
StreamHeartbeat time.Duration // 流式空闲心跳间隔
FirstTokenWait time.Duration // 流式放行前等内容的最长时间(0=不限)
AffinityTTL time.Duration // 会话亲和 TTL(0=关闭)
@@ -267,6 +287,8 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
maxBodyMB: o.MaxBodyMB,
failoverBudget: o.FailoverBudget,
failoverCaps: failoverCapsFrom(o.FailoverSwitchCaps),
cooldownGrace: o.CooldownGrace,
reserveMaxTokens: o.ReserveMaxTokens,
streamHeartbeat: o.StreamHeartbeat,
firstTokenWait: o.FirstTokenWait,
affinityTTL: o.AffinityTTL,
@@ -500,6 +522,23 @@ func (g *Gateway) candidateChannels(model string) []channel.Candidate {
return g.ch.Candidates(model)
}
// candidatesFor 候选获取的统一入口:健康候选为空且配置了冷却宽限时,
// 找冷却最早结束的候选兜底重试一次(429 的 Retry-After 语义本就是"到点再试",
// 与其立刻 503 不如试试最先恢复的那条)。命中兜底时在 context 打标,
// doProxy 选路会放行这个冷却中的渠道。
func (g *Gateway) candidatesFor(c *gin.Context, model string) []channel.Candidate {
cands := g.candidateChannels(model)
if len(cands) > 0 || g.cooldownGrace <= 0 {
return cands
}
cand, remaining, ok := g.ch.EarliestRecovery(model)
if !ok || remaining > g.cooldownGrace {
return nil
}
c.Set(CtxForceCooling, true)
return []channel.Candidate{cand}
}
// resolveUser 取当前用户(含余额)。Auth 中间件已查过一次并放入上下文,
// handler 里直接复用,省掉每请求一次重复查询。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
@@ -518,14 +557,86 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
}
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
// 可用余额 = 余额 - 在途预扣(冻结)额:并发流式请求各持一份冻结,
// 不会互相透支把余额拖到深度负值(冻结语义见 freezeFor)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if !u.Balance.IsPositive() {
avail := u.Balance
if a := g.frozenOf(u.ID); a != nil && a.Load() > 0 {
avail = avail.Sub(decimal.NewFromInt(a.Load()).Shift(-8))
}
if !avail.IsPositive() {
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
// frozenOf 取用户的在途冻结额桶;无冻结返回 nil(零分配快路径)。
func (g *Gateway) frozenOf(uid uint64) *atomic.Int64 {
if v, ok := g.frozen.Load(uid); ok {
return v.(*atomic.Int64)
}
return nil
}
// freezeFor 预扣(reserve):按请求体估算本请求的成本上界并计入冻结额,随 gin context
// 携带,请求终结(含失败/中断/panic,由 handler 的 defer 释放)时按同一数值 capture。
// 估算只求上界不求准:输入按 ~4 字节/token 粗估并用缓存写价(1.25×/2× 输入价)封顶,
// 输出取 max_tokens(缺省用 reserveMaxTokens 基数)。
// 冻结只在本进程内存里,单实例语义;多实例部署时各实例独立冻结(余额本身允许为负兜底)。
// 检查(checkBalance)与冻结之间存在乐观竞窗:最坏超额 = 同时穿过竞窗的请求数,
// 有界且被「余额允许为负」兜底;预扣把无界的流式透支压成了这个常数。
func (g *Gateway) freezeFor(c *gin.Context, uid uint64, model store.Model, br *bodyReq, body []byte) {
if g.reserveMaxTokens <= 0 || model.ID == 0 {
return
}
outTok := int64(g.reserveMaxTokens)
if br != nil {
if br.MaxTokens != nil && *br.MaxTokens > 0 && int64(*br.MaxTokens) < outTok {
outTok = int64(*br.MaxTokens)
}
if br.MaxOutputTokens != nil && *br.MaxOutputTokens > 0 && int64(*br.MaxOutputTokens) < outTok {
outTok = int64(*br.MaxOutputTokens)
}
}
inPrice := model.InputPrice
if cwp := model.CacheWritePriceOr(); cwp.GreaterThan(inPrice) {
inPrice = cwp
}
million := decimal.NewFromInt(1e6)
est := decimal.NewFromInt(int64(len(body) / 4)).Mul(inPrice).
Add(decimal.NewFromInt(outTok).Mul(model.OutputPrice)).
Div(million).Round(8)
n := est.Shift(8).Ceil().BigInt().Int64() // 1e-8 USD 整数,向上取整保上界
if n <= 0 {
return
}
v, _ := g.frozen.LoadOrStore(uid, &atomic.Int64{})
v.(*atomic.Int64).Add(n)
g.frozenTotal.Add(n)
metrics.FrozenBalance.Set(float64(g.frozenTotal.Load()) / 1e8)
c.Set(CtxReserve, n)
}
// unfreeze 释放冻结额(capture)。幂等:CtxReserve 置零后重复调用为 no-op,
// handler 的 defer 与任何终结路径的双释放只生效一次。
func (g *Gateway) unfreeze(c *gin.Context, uid uint64) {
v, ok := c.Get(CtxReserve)
if !ok {
return
}
n, _ := v.(int64)
c.Set(CtxReserve, int64(0))
if n == 0 {
return
}
if a := g.frozenOf(uid); a != nil {
a.Add(-n)
}
total := g.frozenTotal.Add(-n)
metrics.FrozenBalance.Set(float64(total) / 1e8)
}
// ---------------------------------------------------------------------------
// 协议分派
+21 -3
View File
@@ -46,7 +46,13 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
@@ -82,7 +88,13 @@ func (g *Gateway) responses(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
@@ -118,7 +130,13 @@ func (g *Gateway) messages(c *gin.Context) {
return
}
cands := g.candidateChannels(br.Model)
// 预扣(reserve):按 body 估算成本上界计入冻结额;defer 释放(capture),
// 失败/中断/panic 都会走到,finishUsage 的扣款在冻结释放前完成。
model, _ := g.modelByName(br.Model)
g.freezeFor(c, u.ID, model, br, body)
defer g.unfreeze(c, u.ID)
cands := g.candidatesFor(c, br.Model)
if len(cands) == 0 {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
+15 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/pkg/tokenizer"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
@@ -34,6 +35,9 @@ func newTraceID() string {
type bodyReq struct {
Model string `json:"model"`
Stream bool `json:"stream"`
// 预扣估算用(freezeFor):chat/messages 的 max_tokens、responses 的 max_output_tokens
MaxTokens *int `json:"max_tokens"`
MaxOutputTokens *int `json:"max_output_tokens"`
}
// parseBody 读取并回填请求体,解析 model/stream。
@@ -283,6 +287,8 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
// 那不是「上游坏了」(502),而是「我方满了」(503 + Retry-After)。
saturated := false
startAll := time.Now()
// 全冷却兜底:候选来自 EarliestRecovery 时放行冷却中的渠道(只此一次尝试机会)
_, forceCooling := c.Get(CtxForceCooling)
// 换渠道次数按失败类型计数:cap 到了就不再为同类失败换渠道(配置见 failover_caps)。
switches := map[channel.FailKind]int{}
var lastKind channel.FailKind
@@ -311,7 +317,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
g.finishUsage(c, ch, now(), store.UsageStatusCanceled, "client_disconnect")
return
}
if g.ch.InCooldown(ch.ID) {
if !forceCooling && g.ch.InCooldown(ch.ID) {
continue // 本次请求期间已被并发请求判入运行期冷却
}
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
@@ -1071,6 +1077,14 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
traceStr, _ := trace.(string)
latency := int(time.Since(start).Milliseconds())
// 指标:终结计数与耗时。channel 为 "none" 表示没打到任何渠道(无候选/满载等)
chName := "none"
if ch != nil {
chName = ch.Name
}
metrics.RequestsTotal.WithLabelValues(chName, mn, status).Inc()
metrics.RequestDuration.WithLabelValues(chName).Observe(time.Since(start).Seconds())
if status == store.UsageStatusSuccess && c.Writer.Status() >= 400 {
status = store.UsageStatusError
}
+85
View File
@@ -509,6 +509,91 @@ func TestFailoverSwitchCaps(t *testing.T) {
}
}
// 预扣(reserve/capture):在途请求的冻结额让「余额 - 冻结 ≤ 0」触发 402,
// 释放后恢复放行。并发的长流式请求不再互相透支。
func TestReserveFreezesBalance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
h.gw.reserveMaxTokens = 4096
// 余额压到 0.001:4096 out × 0.6/1e6 = 0.00246 的冻结上界必然覆盖它
if err := h.db.Model(&store.User{}).Where("username = ?", "u1").
Update("balance", 0.001).Error; err != nil {
t.Fatal(err)
}
var u store.User
h.db.Where("username = ?", "u1").First(&u)
var model store.Model
h.db.Where("name = ?", "m1").First(&model)
// 模拟一条在途请求的冻结(不释放)
fr, _ := gin.CreateTestContext(httptest.NewRecorder())
fr.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.gw.freezeFor(fr, u.ID, model, &bodyReq{MaxTokens: nil}, make([]byte, 64))
if w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`); w.Code != http.StatusPaymentRequired {
t.Fatalf("冻结额覆盖余额时应 402,实际 %d: %s", w.Code, w.Body.String())
}
// capture(释放)后恢复放行
h.gw.unfreeze(fr, u.ID)
if a := h.gw.frozenOf(u.ID); a != nil && a.Load() != 0 {
t.Fatalf("释放后冻结额应为 0,实际 %d", a.Load())
}
w := h.run(t, nil, `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
if w.Code != http.StatusOK {
t.Fatalf("释放后应正常代理,实际 %d: %s", w.Code, w.Body.String())
}
// 幂等:重复 unfreeze 不产生负冻结
h.gw.unfreeze(fr, u.ID)
if a := h.gw.frozenOf(u.ID); a != nil && a.Load() < 0 {
t.Fatalf("重复释放不应产生负冻结,实际 %d", a.Load())
}
}
// 冷却兜底宽限:唯一渠道进运行期冷却后,请求 503;配置 grace 且上游已恢复时,
// 带上冷却中的渠道兜底重试一次即成功。
func TestCooldownGraceFallback(t *testing.T) {
var healthy atomic.Bool
healthy.Store(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy.Load() {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, `{"error":{"message":"boom"}}`)
return
}
_, _ = io.WriteString(w, `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
h := newHarness(t, srv.URL, 0, true, nil)
body := `{"model":"m1","messages":[{"role":"user","content":"hi"}]}`
// 连续 3 次 5xx:触发运行期冷却(failThreshold=3)
for i := 0; i < 3; i++ {
h.run(t, nil, body)
}
// 默认 grace=0(关闭):冷却中 → 无候选 → 503
if w := h.run(t, nil, body); w.Code != http.StatusServiceUnavailable {
t.Fatalf("冷却中且未配置宽限时应 503,实际 %d: %s", w.Code, w.Body.String())
}
// 上游恢复 + 配置宽限:兜底重试冷却中的渠道 → 200
healthy.Store(true)
h.gw.cooldownGrace = time.Minute
if w := h.run(t, nil, body); w.Code != http.StatusOK {
t.Fatalf("宽限期内应兜底重试恢复的渠道,实际 %d: %s", w.Code, w.Body.String())
}
// 冷却截止时间未被成功清除(由 InCooldown 到期惰性恢复,语义不变),
// 但宽限期内每次请求都能经兜底打到这条渠道
if w := h.run(t, nil, body); w.Code != http.StatusOK {
t.Fatalf("宽限期内后续请求也应走兜底,实际 %d: %s", w.Code, w.Body.String())
}
}
// 流式空闲时补 SSE 注释心跳:上游长时间不出字(长思考)不能被反代空闲超时掐掉。
func TestStreamHeartbeatWhileIdle(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+11
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"github.com/openteam/server/internal/pkg/metrics"
"github.com/openteam/server/internal/store"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -115,6 +116,16 @@ func (r *Recorder) Record(l *store.UsageLog) {
r.mu.RUnlock()
r.flushRetry([]*store.UsageLog{l})
}
r.updateQueueDepth()
}
// updateQueueDepth 刷新记账队列深度指标(多 recorder 并存时后写者胜,无碍)。
func (r *Recorder) updateQueueDepth() {
var depth int
for _, ch := range r.shards {
depth += len(ch)
}
metrics.RecorderQueueDepth.Set(float64(depth))
}
// Close 停止接收新记账并等待后台 goroutine 排空队列后返回。幂等。