Compare commits
19
Commits
512e46664c
...
8194edb63d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8194edb63d | ||
|
|
6f895acf63 | ||
|
|
fb7db00817 | ||
|
|
0ee92d78e7 | ||
|
|
4b507d8064 | ||
|
|
7f28c8b83b | ||
|
|
47f6a8daaa | ||
|
|
e96f194a59 | ||
|
|
db83972b6f | ||
|
|
7c4e80afac | ||
|
|
9324a782d5 | ||
|
|
057b1b2c0b | ||
|
|
40c1ae43e9 | ||
|
|
b365779188 | ||
|
|
866690b7ce | ||
|
|
298bf89c90 | ||
|
|
79f3f4395c | ||
|
|
4d4d09ba58 | ||
|
|
8d9940ad5a |
@@ -36,6 +36,11 @@ OT_PROXY_HEALTH_FAIL_THRESHOLD=2
|
|||||||
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
||||||
OT_RATELIMIT_USER_RPS=20
|
OT_RATELIMIT_USER_RPS=20
|
||||||
|
|
||||||
|
# Passkey(WebAuthn):RPID 为域名,RPOrigin 为前端来源(需 HTTPS 或 localhost)
|
||||||
|
OT_WEBAUTHN_RP_ID=localhost
|
||||||
|
OT_WEBAUTHN_RP_ORIGIN=http://localhost:5173
|
||||||
|
OT_WEBAUTHN_RP_NAME=openteam
|
||||||
|
|
||||||
# 初始管理员(仅首次创建生效)
|
# 初始管理员(仅首次创建生效)
|
||||||
OT_ADMIN_USERNAME=admin
|
OT_ADMIN_USERNAME=admin
|
||||||
OT_ADMIN_EMAIL=admin@localhost
|
OT_ADMIN_EMAIL=admin@localhost
|
||||||
|
|||||||
@@ -34,3 +34,6 @@ nohup.out
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# 测试结果记录(参考用, 不入库)
|
||||||
|
docs/*.txt
|
||||||
|
|||||||
+12
-3
@@ -1,12 +1,14 @@
|
|||||||
module github.com/openteam/server
|
module github.com/openteam/server
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.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/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/spf13/viper v1.21.0
|
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/crypto v0.55.0
|
||||||
gorm.io/driver/postgres v1.6.2
|
gorm.io/driver/postgres v1.6.2
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
@@ -17,18 +19,22 @@ require (
|
|||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
@@ -42,6 +48,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -51,8 +58,10 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
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.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.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
+24
-4
@@ -9,12 +9,16 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
@@ -33,8 +37,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
@@ -43,11 +51,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -81,6 +93,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
@@ -118,10 +132,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
|
||||||
|
github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -46,14 +48,59 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Role *string `json:"role"`
|
Username *string `json:"username"`
|
||||||
Status *string `json:"status"`
|
Email *string `json:"email"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
Role *string `json:"role"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
AllowedModels *[]string `json:"allowed_models"`
|
||||||
|
DeniedModels *[]string `json:"denied_models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
|
if req.Username != nil {
|
||||||
|
u := strings.TrimSpace(*req.Username)
|
||||||
|
if len(u) < 3 || len(u) > 32 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "username must be 3-32 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
h.a.DB.Model(&store.User{}).Where("username = ? AND id != ?", u, id).Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
resp.Fail(c, http.StatusConflict, "username already taken")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["username"] = u
|
||||||
|
}
|
||||||
|
if req.Email != nil {
|
||||||
|
e := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||||
|
if _, err := mail.ParseAddress(e); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
h.a.DB.Model(&store.User{}).Where("email = ? AND id != ?", e, id).Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
resp.Fail(c, http.StatusConflict, "email already taken")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["email"] = e
|
||||||
|
}
|
||||||
|
if req.Password != nil && *req.Password != "" {
|
||||||
|
if len(*req.Password) < 8 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "password must be at least 8 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := h.a.Hasher.HashPassword(*req.Password)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["password_hash"] = hash
|
||||||
|
}
|
||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
|
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
|
||||||
resp.Fail(c, http.StatusBadRequest, "role must be user or admin")
|
resp.Fail(c, http.StatusBadRequest, "role must be user or admin")
|
||||||
@@ -68,6 +115,15 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updates["status"] = *req.Status
|
updates["status"] = *req.Status
|
||||||
}
|
}
|
||||||
|
// 模型限制(jsonb):手动序列化
|
||||||
|
if req.AllowedModels != nil {
|
||||||
|
raw, _ := json.Marshal(*req.AllowedModels)
|
||||||
|
updates["allowed_models"] = string(raw)
|
||||||
|
}
|
||||||
|
if req.DeniedModels != nil {
|
||||||
|
raw, _ := json.Marshal(*req.DeniedModels)
|
||||||
|
updates["denied_models"] = string(raw)
|
||||||
|
}
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
resp.OK(c, gin.H{"ok": true})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
return
|
return
|
||||||
@@ -166,5 +222,9 @@ func (h *Handler) AdminPutConfig(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusInternalServerError, "failed to save config")
|
resp.Fail(c, http.StatusInternalServerError, "failed to save config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 模型限制等策略可能变化,立即失效缓存
|
||||||
|
if h.gw != nil {
|
||||||
|
h.gw.ResetModelPolicy()
|
||||||
|
}
|
||||||
resp.OK(c, gin.H{"ok": true})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
|
||||||
|
// 拉取渠道接口的模型列表(仅预览,不绑定)。
|
||||||
|
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ch.UpstreamURL("", "/models"), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp2, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]string, 0, len(list.Data))
|
||||||
|
for _, m := range list.Data {
|
||||||
|
if strings.TrimSpace(m.ID) != "" {
|
||||||
|
items = append(items, strings.TrimSpace(m.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelModels GET /api/v1/admin/channels/:id/models — 渠道的模型绑定列表(含上游映射名)。
|
||||||
|
func (h *Handler) AdminChannelModels(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var bindings []store.ChannelModelBinding
|
||||||
|
h.a.DB.Preload("Model").Where("channel_id = ?", id).Order("id ASC").Find(&bindings)
|
||||||
|
out := make([]gin.H, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": b.ID,
|
||||||
|
"model_id": b.ModelID,
|
||||||
|
"model_name": b.Model.Name,
|
||||||
|
"upstream_model": b.UpstreamModel,
|
||||||
|
"weight": b.Weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelAddModel POST /api/v1/admin/channels/:id/models — 手工添加渠道支持的模型。
|
||||||
|
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
|
||||||
|
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
|
||||||
|
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalName := req.CustomName
|
||||||
|
if globalName == "" {
|
||||||
|
globalName = req.UpstreamModel
|
||||||
|
}
|
||||||
|
// 解析或创建全局模型(客户端名)
|
||||||
|
var m store.Model
|
||||||
|
if err := h.a.DB.Where("name = ?", globalName).First(&m).Error; err != nil {
|
||||||
|
m = store.Model{Name: globalName, DisplayName: globalName, Enabled: true}
|
||||||
|
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := store.ChannelModelBinding{
|
||||||
|
ChannelID: id, ModelID: m.ID, UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
||||||
|
}
|
||||||
|
if err := h.a.DB.Create(&b).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusConflict, "binding may already exist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Created(c, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelUpdateModel PATCH /api/v1/admin/channels/:id/models/:bid — 改映射名/权重。
|
||||||
|
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
|
||||||
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UpstreamModel *string `json:"upstream_model"`
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
if req.UpstreamModel != nil {
|
||||||
|
updates["upstream_model"] = *req.UpstreamModel
|
||||||
|
}
|
||||||
|
if req.Weight != nil {
|
||||||
|
updates["weight"] = *req.Weight
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
res := h.a.DB.Model(&store.ChannelModelBinding{}).Where("id = ?", bid).Updates(updates)
|
||||||
|
if res.Error != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to update binding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "binding not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelDeleteModel DELETE /api/v1/admin/channels/:id/models/:bid — 解除绑定。
|
||||||
|
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
|
||||||
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid)
|
||||||
|
if res.Error != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete binding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "binding not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
|||||||
masked = "****"
|
masked = "****"
|
||||||
}
|
}
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL,
|
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
|
||||||
|
"base_url": ch.BaseURL, "base_urls": ch.BaseURLs,
|
||||||
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
||||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||||
@@ -44,19 +45,37 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type channelBody struct {
|
type channelBody struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
||||||
BaseURL string `json:"base_url"` // 可选:为空时按供应商默认(openai/anthropic)
|
BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点
|
||||||
APIKey string `json:"api_key"`
|
BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages)
|
||||||
Weight *int `json:"weight"`
|
APIKey string `json:"api_key"`
|
||||||
Priority *int `json:"priority"`
|
Weight *int `json:"weight"`
|
||||||
TimeoutMS *int `json:"timeout_ms"`
|
Priority *int `json:"priority"`
|
||||||
MaxConcurrency *int `json:"max_concurrency"`
|
TimeoutMS *int `json:"timeout_ms"`
|
||||||
Enabled *bool `json:"enabled"`
|
MaxConcurrency *int `json:"max_concurrency"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBaseURL 渠道 base_url:留空按供应商默认;兼容用户填完整地址(含 /v1)。
|
// normalizeBaseURLs 校验并清理分协议 base_url。
|
||||||
|
func normalizeBaseURLs(m map[string]string) map[string]string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range m {
|
||||||
|
if validFormats[k] && strings.TrimSpace(v) != "" {
|
||||||
|
out[k] = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
|
||||||
func resolveBaseURL(provider, raw string) (string, error) {
|
func resolveBaseURL(provider, raw string) (string, error) {
|
||||||
base := strings.TrimRight(raw, "/")
|
base := strings.TrimRight(raw, "/")
|
||||||
if base == "" {
|
if base == "" {
|
||||||
@@ -70,7 +89,7 @@ func resolveBaseURL(provider, raw string) (string, error) {
|
|||||||
if base == "" {
|
if base == "" {
|
||||||
return "", errors.New("base_url required for compatible channels")
|
return "", errors.New("base_url required for compatible channels")
|
||||||
}
|
}
|
||||||
return strings.TrimSuffix(base, "/v1"), nil
|
return base, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateProvider(p string) bool {
|
func validateProvider(p string) bool {
|
||||||
@@ -165,6 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
ch := store.Channel{
|
ch := store.Channel{
|
||||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||||
|
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||||
@@ -184,17 +204,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Provider *string `json:"provider"`
|
Provider *string `json:"provider"`
|
||||||
Formats *[]string `json:"formats"`
|
Formats *[]string `json:"formats"`
|
||||||
BaseURL *string `json:"base_url"`
|
BaseURL *string `json:"base_url"`
|
||||||
APIKey *string `json:"api_key"`
|
BaseURLs *map[string]string `json:"base_urls"`
|
||||||
Weight *int `json:"weight"`
|
APIKey *string `json:"api_key"`
|
||||||
Priority *int `json:"priority"`
|
Weight *int `json:"weight"`
|
||||||
TimeoutMS *int `json:"timeout_ms"`
|
Priority *int `json:"priority"`
|
||||||
MaxConcurrency *int `json:"max_concurrency"`
|
TimeoutMS *int `json:"timeout_ms"`
|
||||||
HealthStatus *string `json:"health_status"`
|
MaxConcurrency *int `json:"max_concurrency"`
|
||||||
Enabled *bool `json:"enabled"`
|
HealthStatus *string `json:"health_status"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
@@ -228,6 +249,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updates["base_url"] = b
|
updates["base_url"] = b
|
||||||
}
|
}
|
||||||
|
if body.BaseURLs != nil {
|
||||||
|
// base_urls 是 jsonb:手动序列化
|
||||||
|
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
|
||||||
|
updates["base_urls"] = string(raw)
|
||||||
|
}
|
||||||
if body.APIKey != nil && *body.APIKey != "" {
|
if body.APIKey != nil && *body.APIKey != "" {
|
||||||
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,20 +290,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusBadRequest, ferr.Error())
|
resp.Fail(c, http.StatusBadRequest, ferr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates["formats"] = formats
|
// formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化)
|
||||||
|
raw, _ := json.Marshal(formats)
|
||||||
|
updates["formats"] = string(raw)
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
|
resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// jsonb 序列化走模型字段更新
|
|
||||||
if f, ok := updates["formats"]; ok {
|
|
||||||
if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil {
|
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to update formats")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
resp.OK(c, gin.H{"ok": true})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -320,7 +341,7 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
url := ch.UpstreamURL("", "/models")
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+key)
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
@@ -368,7 +389,7 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
url := ch.UpstreamURL("", "/models")
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+key)
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,13 +12,17 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。
|
// AdminModels GET /api/v1/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
|
||||||
func (h *Handler) AdminModels(c *gin.Context) {
|
func (h *Handler) AdminModels(c *gin.Context) {
|
||||||
var ms []store.Model
|
var ms []store.Model
|
||||||
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局模型限制策略
|
||||||
|
allow, deny := h.modelPolicyConfig()
|
||||||
|
|
||||||
out := make([]gin.H, 0, len(ms))
|
out := make([]gin.H, 0, len(ms))
|
||||||
for _, m := range ms {
|
for _, m := range ms {
|
||||||
var bindings []store.ChannelModelBinding
|
var bindings []store.ChannelModelBinding
|
||||||
@@ -29,13 +34,79 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
|||||||
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
used := len(bindings) > 0
|
||||||
|
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||||
|
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
||||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||||
|
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resp.OK(c, gin.H{"items": out})
|
|
||||||
|
// 渠道提供了但目录中缺失的模型(孤儿绑定,数据不一致时出现)
|
||||||
|
var orphans []struct {
|
||||||
|
ChannelName string
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.a.DB.Raw(`SELECT c.name as channel_name, b.model_id
|
||||||
|
FROM channel_model_bindings b
|
||||||
|
LEFT JOIN models m ON m.id = b.model_id
|
||||||
|
LEFT JOIN channels c ON c.id = b.channel_id
|
||||||
|
WHERE m.id IS NULL`).Scan(&orphans)
|
||||||
|
missing := make([]gin.H, 0, len(orphans))
|
||||||
|
for _, o := range orphans {
|
||||||
|
missing = append(missing, gin.H{"channel": o.ChannelName, "model_id": o.ModelID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在用但未定价的模型数(渠道已提供、需定价)
|
||||||
|
unpriced := 0
|
||||||
|
{
|
||||||
|
var usedBindings []struct {
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.a.DB.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
|
||||||
|
usedIDs := map[uint64]bool{}
|
||||||
|
for _, u := range usedBindings {
|
||||||
|
usedIDs[u.ModelID] = true
|
||||||
|
}
|
||||||
|
for _, m := range ms {
|
||||||
|
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
|
||||||
|
unpriced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.OK(c, gin.H{
|
||||||
|
"items": out,
|
||||||
|
"summary": gin.H{
|
||||||
|
"total": len(ms),
|
||||||
|
"unpriced": unpriced,
|
||||||
|
"missing": missing,
|
||||||
|
"denied_count": len(deny),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPolicyConfig 读取全局模型允许/禁止列表。
|
||||||
|
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
|
||||||
|
var raw string
|
||||||
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &allow)
|
||||||
|
raw = ""
|
||||||
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCreateModel POST /api/v1/admin/models
|
// AdminCreateModel POST /api/v1/admin/models
|
||||||
|
|||||||
@@ -8,18 +8,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/openteam/server/internal/app"
|
|
||||||
"github.com/openteam/server/internal/api/middleware"
|
"github.com/openteam/server/internal/api/middleware"
|
||||||
|
"github.com/openteam/server/internal/app"
|
||||||
|
"github.com/openteam/server/internal/passkey"
|
||||||
"github.com/openteam/server/internal/pkg/resp"
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
|
"github.com/openteam/server/internal/proxy"
|
||||||
"github.com/openteam/server/internal/store"
|
"github.com/openteam/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler 聚合所有管理 API。
|
// Handler 聚合所有管理 API。
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
a *app.App
|
a *app.App
|
||||||
|
gw *proxy.Gateway
|
||||||
|
passkeys *passkey.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(a *app.App) *Handler { return &Handler{a: a} }
|
func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
|
||||||
|
return &Handler{a: a, gw: gw, passkeys: pk}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 认证
|
// 认证
|
||||||
@@ -219,6 +225,8 @@ func (h *Handler) publicUser(u *store.User) gin.H {
|
|||||||
"role": u.Role,
|
"role": u.Role,
|
||||||
"balance": u.Balance,
|
"balance": u.Balance,
|
||||||
"status": u.Status,
|
"status": u.Status,
|
||||||
|
"allowed_models": u.AllowedModels,
|
||||||
|
"denied_models": u.DeniedModels,
|
||||||
"created_at": u.CreatedAt,
|
"created_at": u.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
|||||||
resp.OK(c, gin.H{"ok": true})
|
resp.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteKey DELETE /api/v1/keys/:id — 吊销。
|
// DeleteKey DELETE /api/v1/keys/:id — 删除(硬删,立即失效不可恢复)。
|
||||||
func (h *Handler) DeleteKey(c *gin.Context) {
|
func (h *Handler) DeleteKey(c *gin.Context) {
|
||||||
u, ok := userFromContext(c)
|
u, ok := userFromContext(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -172,11 +172,9 @@ func (h *Handler) DeleteKey(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusBadRequest, "invalid key id")
|
resp.Fail(c, http.StatusBadRequest, "invalid key id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res := h.a.DB.Model(&store.APIKey{}).
|
res := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).Delete(&store.APIKey{})
|
||||||
Where("id = ? AND user_id = ?", id, u.ID).
|
|
||||||
Update("status", store.KeyStatusRevoked)
|
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key")
|
resp.Fail(c, http.StatusInternalServerError, "failed to delete key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
if res.RowsAffected == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasskeyRegisterBegin POST /api/v1/webauthn/register/begin — 生成注册选项。
|
||||||
|
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
creation, err := h.passkeys.BeginRegistration(u)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin registration: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"creation": creation, "challenge": creation.Response.Challenge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegisterComplete POST /api/v1/webauthn/register/complete — 校验并保存凭据。
|
||||||
|
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, []byte(req.Name)); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "passkey 注册失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginBegin POST /api/v1/webauthn/login/begin — 生成断言选项。
|
||||||
|
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
|
||||||
|
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
if req.Username != "" {
|
||||||
|
var u store.User
|
||||||
|
err := h.a.DB.Where("username = ? OR email = ?", req.Username, req.Username).First(&u).Error
|
||||||
|
if err != nil || u.Status != store.UserStatusActive {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginLogin(&u)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginComplete POST /api/v1/webauthn/login/complete — 校验断言并发放令牌。
|
||||||
|
func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var u *store.User
|
||||||
|
if req.UserID > 0 {
|
||||||
|
var su store.User
|
||||||
|
if err := h.a.DB.First(&su, req.UserID).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u = &su
|
||||||
|
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Status != store.UserStatusActive {
|
||||||
|
resp.Fail(c, http.StatusForbidden, "user account disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.setRefreshCookie(c, refresh)
|
||||||
|
resp.OK(c, gin.H{
|
||||||
|
"access_token": access,
|
||||||
|
"expires_in": int(h.a.JWT.AccessTTL().Seconds()),
|
||||||
|
"user": h.publicUser(u),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyList GET /api/v1/webauthn/passkeys — 当前用户的 passkey 列表。
|
||||||
|
func (h *Handler) PasskeyList(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
pks, err := h.passkeys.List(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to load passkeys")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt})
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyDelete DELETE /api/v1/webauthn/passkeys/:id — 解除绑定。
|
||||||
|
func (h *Handler) PasskeyDelete(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid passkey id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.Delete(u.ID, id); err != nil {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "passkey not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||||
|
|
||||||
h := NewHandler(a)
|
h := NewHandler(a, gw, a.Passkeys)
|
||||||
|
|
||||||
// --- 代理端点(对外)---
|
// --- 代理端点(对外)---
|
||||||
proxyGroup := r.Group("/v1")
|
proxyGroup := r.Group("/v1")
|
||||||
@@ -67,6 +67,16 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webauthn := api.Group("/webauthn")
|
||||||
|
{
|
||||||
|
webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin)
|
||||||
|
webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete)
|
||||||
|
webauthn.POST("/login/begin", h.PasskeyLoginBegin)
|
||||||
|
webauthn.POST("/login/complete", h.PasskeyLoginComplete)
|
||||||
|
webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList)
|
||||||
|
webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete)
|
||||||
|
}
|
||||||
|
|
||||||
user := api.Group("", middleware.SessionAuth(a))
|
user := api.Group("", middleware.SessionAuth(a))
|
||||||
{
|
{
|
||||||
user.GET("/user/profile", h.UserProfile)
|
user.GET("/user/profile", h.UserProfile)
|
||||||
@@ -94,6 +104,11 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||||||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||||||
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
|
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
|
||||||
|
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
||||||
|
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
||||||
|
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
||||||
|
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
|
||||||
|
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
|
||||||
// 模型与定价
|
// 模型与定价
|
||||||
admin.GET("/models", h.AdminModels)
|
admin.GET("/models", h.AdminModels)
|
||||||
admin.POST("/models", h.AdminCreateModel)
|
admin.POST("/models", h.AdminCreateModel)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/openteam/server/internal/channel"
|
"github.com/openteam/server/internal/channel"
|
||||||
"github.com/openteam/server/internal/config"
|
"github.com/openteam/server/internal/config"
|
||||||
|
"github.com/openteam/server/internal/passkey"
|
||||||
"github.com/openteam/server/internal/pkg/crypto"
|
"github.com/openteam/server/internal/pkg/crypto"
|
||||||
"github.com/openteam/server/internal/pkg/jwt"
|
"github.com/openteam/server/internal/pkg/jwt"
|
||||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||||
@@ -25,6 +26,7 @@ type App struct {
|
|||||||
Usage *usage.Recorder
|
Usage *usage.Recorder
|
||||||
Health *channel.HealthMonitor
|
Health *channel.HealthMonitor
|
||||||
Limit *ratelimit.Limiter
|
Limit *ratelimit.Limiter
|
||||||
|
Passkeys *passkey.Service
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -57,6 +59,13 @@ func New(cfg *config.Config) (*App, error) {
|
|||||||
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
||||||
})
|
})
|
||||||
a.Health.Start(a.ctx)
|
a.Health.Start(a.ctx)
|
||||||
|
|
||||||
|
a.Passkeys, err = passkey.New(db, passkey.Config{
|
||||||
|
RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,69 +27,74 @@ func NewService(db *gorm.DB, enc *crypto.Encryptor) *Service {
|
|||||||
return &Service{db: db, enc: enc, sems: map[uint64]chan struct{}{}}
|
return &Service{db: db, enc: enc, sems: map[uint64]chan struct{}{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Candidate 一个候选渠道 + 该模型的映射关系。
|
||||||
|
type Candidate struct {
|
||||||
|
Channel *store.Channel
|
||||||
|
UpstreamModel string // 全局模型在此渠道的映射名(无绑定则为空,用客户端模型名)
|
||||||
|
}
|
||||||
|
|
||||||
// Candidates 返回可用渠道候选:健康 + 启用,按优先级、权重降序、id 升序排列。
|
// Candidates 返回可用渠道候选:健康 + 启用,按优先级、权重降序、id 升序排列。
|
||||||
// model 非空时优先取绑定该模型的渠道;无绑定则退回全局。
|
// model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射);无绑定则退回全局。
|
||||||
func (s *Service) Candidates(model string) []*store.Channel {
|
func (s *Service) Candidates(model string) []Candidate {
|
||||||
if model != "" {
|
if model != "" {
|
||||||
var b []store.ChannelModelBinding
|
var b []store.ChannelModelBinding
|
||||||
var modelIDs []uint64
|
var modelIDs []uint64
|
||||||
s.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
|
s.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
|
||||||
if len(modelIDs) > 0 {
|
if len(modelIDs) > 0 {
|
||||||
s.db.Where("model_id IN ?", modelIDs).Find(&b)
|
s.db.Where("model_id IN ?", modelIDs).Find(&b)
|
||||||
chs := s.loadBound(b)
|
if cands := s.loadBound(b); len(cands) > 0 {
|
||||||
if len(chs) > 0 {
|
return cands
|
||||||
return chs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var chs []store.Channel
|
var chs []store.Channel
|
||||||
s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy).
|
s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy).
|
||||||
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||||
out := make([]*store.Channel, 0, len(chs))
|
out := make([]Candidate, 0, len(chs))
|
||||||
for i := range chs {
|
for i := range chs {
|
||||||
out = append(out, &chs[i])
|
out = append(out, Candidate{Channel: &chs[i]})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadBound 按绑定顺序加载渠道,过滤健康/启用。
|
// loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
|
||||||
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []*store.Channel {
|
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
|
||||||
if len(bindings) == 0 {
|
if len(bindings) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// channel_id -> 绑定(取该渠道对该模型的映射)
|
||||||
|
byChannel := map[uint64]store.ChannelModelBinding{}
|
||||||
ids := make([]uint64, 0, len(bindings))
|
ids := make([]uint64, 0, len(bindings))
|
||||||
seen := map[uint64]bool{}
|
|
||||||
for _, b := range bindings {
|
for _, b := range bindings {
|
||||||
if !seen[b.ChannelID] {
|
if _, ok := byChannel[b.ChannelID]; !ok {
|
||||||
seen[b.ChannelID] = true
|
|
||||||
ids = append(ids, b.ChannelID)
|
ids = append(ids, b.ChannelID)
|
||||||
}
|
}
|
||||||
|
byChannel[b.ChannelID] = b
|
||||||
}
|
}
|
||||||
var chs []store.Channel
|
var chs []store.Channel
|
||||||
s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
|
s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
|
||||||
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||||
// 保持绑定顺序
|
|
||||||
byID := map[uint64]*store.Channel{}
|
byID := map[uint64]*store.Channel{}
|
||||||
for i := range chs {
|
for i := range chs {
|
||||||
byID[chs[i].ID] = &chs[i]
|
byID[chs[i].ID] = &chs[i]
|
||||||
}
|
}
|
||||||
out := make([]*store.Channel, 0, len(ids))
|
out := make([]Candidate, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if ch, ok := byID[id]; ok {
|
if ch, ok := byID[id]; ok {
|
||||||
out = append(out, ch)
|
out = append(out, Candidate{Channel: ch, UpstreamModel: byChannel[id].UpstreamModel})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick 按权重加权随机选一个候选(负载均衡)。
|
// Pick 按权重加权随机选一个候选渠道(负载均衡)。
|
||||||
func (s *Service) Pick(cands []*store.Channel) *store.Channel {
|
func (s *Service) Pick(cands []Candidate) *store.Channel {
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
total := 0
|
total := 0
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
w := c.Weight
|
w := c.Channel.Weight
|
||||||
if w <= 0 {
|
if w <= 0 {
|
||||||
w = 1
|
w = 1
|
||||||
}
|
}
|
||||||
@@ -98,16 +103,16 @@ func (s *Service) Pick(cands []*store.Channel) *store.Channel {
|
|||||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
||||||
acc := 0
|
acc := 0
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
w := c.Weight
|
w := c.Channel.Weight
|
||||||
if w <= 0 {
|
if w <= 0 {
|
||||||
w = 1
|
w = 1
|
||||||
}
|
}
|
||||||
acc += w
|
acc += w
|
||||||
if int(n.Int64()) < acc {
|
if int(n.Int64()) < acc {
|
||||||
return c
|
return c.Channel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cands[len(cands)-1]
|
return cands[len(cands)-1].Channel
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
|
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ func TestCandidatesFiltersUnhealthy(t *testing.T) {
|
|||||||
if len(cands) != 2 {
|
if len(cands) != 2 {
|
||||||
t.Fatalf("candidates = %d, want 2", len(cands))
|
t.Fatalf("candidates = %d, want 2", len(cands))
|
||||||
}
|
}
|
||||||
if cands[0].Name != "a" {
|
if cands[0].Channel.Name != "a" {
|
||||||
t.Fatalf("first by priority should be a, got %s", cands[0].Name)
|
t.Fatalf("first by priority should be a, got %s", cands[0].Channel.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) {
|
|||||||
h.record(ch, false)
|
h.record(ch, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
url := ch.UpstreamURL("", "/models")
|
||||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+key)
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ type Config struct {
|
|||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
Proxy ProxyConfig
|
Proxy ProxyConfig
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
|
WebAuthn WebAuthnConfig
|
||||||
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
|
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAuthnConfig Passkey(WebAuthn)配置。
|
||||||
|
type WebAuthnConfig struct {
|
||||||
|
RPID string // Relying Party ID(域名,如 localhost)
|
||||||
|
RPOrigin string // 前端来源,如 http://localhost:5173
|
||||||
|
RPName string // 展示名
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
|
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
UserRPS int // 用户级每秒请求数上限(0=不限制)
|
UserRPS int // 用户级每秒请求数上限(0=不限制)
|
||||||
@@ -124,6 +132,10 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
v.SetDefault("ratelimit.user_rps", 20)
|
v.SetDefault("ratelimit.user_rps", 20)
|
||||||
|
|
||||||
|
v.SetDefault("webauthn.rp_id", "localhost")
|
||||||
|
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
|
||||||
|
v.SetDefault("webauthn.rp_name", "openteam")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Env: v.GetString("env"),
|
Env: v.GetString("env"),
|
||||||
Port: v.GetInt("port"),
|
Port: v.GetInt("port"),
|
||||||
@@ -160,6 +172,11 @@ func Load() (*Config, error) {
|
|||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
UserRPS: v.GetInt("ratelimit.user_rps"),
|
UserRPS: v.GetInt("ratelimit.user_rps"),
|
||||||
},
|
},
|
||||||
|
WebAuthn: WebAuthnConfig{
|
||||||
|
RPID: v.GetString("webauthn.rp_id"),
|
||||||
|
RPOrigin: v.GetString("webauthn.rp_origin"),
|
||||||
|
RPName: v.GetString("webauthn.rp_name"),
|
||||||
|
},
|
||||||
Master: v.GetString("master_key"),
|
Master: v.GetString("master_key"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Package passkey 封装 WebAuthn(passkey)注册与登录。
|
||||||
|
// 依赖 go-webauthn 库;RPID/RPOrigin 由配置提供。
|
||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
RPID string
|
||||||
|
Origin string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。
|
||||||
|
type Service struct {
|
||||||
|
wa *webauthn.WebAuthn
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]webauthn.SessionData // keyed by challenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB, cfg Config) (*Service, error) {
|
||||||
|
wa, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPDisplayName: cfg.Name,
|
||||||
|
RPID: cfg.RPID,
|
||||||
|
RPOrigins: []string{cfg.Origin},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{wa: wa, db: db, sessions: map[string]webauthn.SessionData{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webUser 实现 go-webauthn 的 User 接口。
|
||||||
|
type webUser struct {
|
||||||
|
id uint64
|
||||||
|
name string
|
||||||
|
displayName string
|
||||||
|
credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) }
|
||||||
|
func (u *webUser) WebAuthnName() string { return u.name }
|
||||||
|
func (u *webUser) WebAuthnDisplayName() string { return u.displayName }
|
||||||
|
func (u *webUser) WebAuthnIcon() string { return "" }
|
||||||
|
func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||||
|
|
||||||
|
func (s *Service) loadWebUser(u *store.User) (*webUser, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
s.db.Where("user_id = ?", u.ID).Find(&pks)
|
||||||
|
creds := make([]webauthn.Credential, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
var c webauthn.Credential
|
||||||
|
if err := json.Unmarshal(pk.Credential, &c); err == nil {
|
||||||
|
creds = append(creds, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 注册
|
||||||
|
|
||||||
|
// BeginRegistration 生成注册选项并暂存 challenge。
|
||||||
|
func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creation, session, err := s.wa.BeginRegistration(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return creation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRegistration 校验浏览器返回的凭据并落库。
|
||||||
|
func (s *Service) FinishRegistration(u *store.User, challenge string, body, name []byte) error {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishRegistration(wu, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
nm := string(name)
|
||||||
|
if nm == "" {
|
||||||
|
nm = "passkey"
|
||||||
|
}
|
||||||
|
return s.db.Create(&store.Passkey{
|
||||||
|
UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 登录
|
||||||
|
|
||||||
|
// BeginLogin 已知用户(按用户名)发起断言。
|
||||||
|
func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assertion, session, err := s.wa.BeginLogin(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
|
||||||
|
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
|
||||||
|
assertion, session, err := s.wa.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin 校验断言并更新签名计数。
|
||||||
|
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishLogin(wu, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.updateCredential(u.ID, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。
|
||||||
|
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
|
var pk store.Passkey
|
||||||
|
if err := s.db.Where("credential_id = ?", rawID).First(&pk).Error; err != nil {
|
||||||
|
return nil, errors.New("credential not found")
|
||||||
|
}
|
||||||
|
var u store.User
|
||||||
|
if err := s.db.First(&u, pk.UserID).Error; err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return s.loadWebUser(&u)
|
||||||
|
}
|
||||||
|
user, cred, err := s.wa.FinishPasskeyLogin(handler, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wu := user.(*webUser)
|
||||||
|
_ = s.updateCredential(wu.id, cred)
|
||||||
|
var st store.User
|
||||||
|
if err := s.db.First(&st, wu.id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 管理
|
||||||
|
|
||||||
|
// List 列出用户的 passkey。
|
||||||
|
func (s *Service) List(userID uint64) ([]store.Passkey, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error
|
||||||
|
return pks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户的 passkey。
|
||||||
|
func (s *Service) Delete(userID, id uint64) error {
|
||||||
|
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error {
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
return s.db.Model(&store.Passkey{}).
|
||||||
|
Where("user_id = ? AND credential_id = ?", userID, cred.ID).
|
||||||
|
Update("credential", raw).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// challenge 会话
|
||||||
|
|
||||||
|
func (s *Service) storeSession(session *webauthn.SessionData) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[session.Challenge] = *session
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.sessions[challenge]
|
||||||
|
if ok {
|
||||||
|
delete(s.sessions, challenge)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok && time.Now().After(sess.Expires) {
|
||||||
|
return webauthn.SessionData{}, false
|
||||||
|
}
|
||||||
|
return sess, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Package tokenizer 按模型估算 token 数(tiktoken-go)。
|
||||||
|
// 用于流式中断时对已生成内容做近似计费;未知模型回退 cl100k_base。
|
||||||
|
package tokenizer
|
||||||
|
|
||||||
|
import "github.com/tiktoken-go/tokenizer"
|
||||||
|
|
||||||
|
// Count 估算文本 token 数;空文本返回 0。
|
||||||
|
func Count(text, model string) int {
|
||||||
|
if text == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
enc, err := tokenizer.ForModel(tokenizer.Model(model))
|
||||||
|
if err != nil {
|
||||||
|
enc, err = tokenizer.Get(tokenizer.Cl100kBase)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toks, _, err := enc.Encode(text)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(toks)
|
||||||
|
}
|
||||||
@@ -306,3 +306,246 @@ func TestStreamMessagesToResponses(t *testing.T) {
|
|||||||
t.Fatalf("missing response.completed: %s", out)
|
t.Fatalf("missing response.completed: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 图片内容块转换(messages image / chat image_url / responses input_image 互转不丢图)
|
||||||
|
|
||||||
|
const testB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||||
|
|
||||||
|
// userContentBlocks 断言 Chat 用户消息 content 为块数组并返回。
|
||||||
|
func userContentBlocks(t *testing.T, out []byte) []any {
|
||||||
|
t.Helper()
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(out, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
msgs := m["messages"].([]any)
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("messages len = %d: %s", len(msgs), out)
|
||||||
|
}
|
||||||
|
content, ok := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("content not array: %s", out)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockURL 取 image_url 块的 url。
|
||||||
|
func blockURL(t *testing.T, b any) string {
|
||||||
|
t.Helper()
|
||||||
|
bm := b.(map[string]any)
|
||||||
|
if bm["type"] != "image_url" {
|
||||||
|
t.Fatalf("block type = %v", bm["type"])
|
||||||
|
}
|
||||||
|
iu := bm["image_url"].(map[string]any)
|
||||||
|
url, _ := iu["url"].(string)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesToChatReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "describe"},
|
||||||
|
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": testB64}},
|
||||||
|
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/a.png"}},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
blocks := userContentBlocks(t, out)
|
||||||
|
if len(blocks) != 3 {
|
||||||
|
t.Fatalf("blocks len = %d: %s", len(blocks), out)
|
||||||
|
}
|
||||||
|
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
|
||||||
|
t.Fatalf("base64 image url = %q", got)
|
||||||
|
}
|
||||||
|
if got := blockURL(t, blocks[2]); got != "https://example.com/a.png" {
|
||||||
|
t.Fatalf("url image = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多图 + 图/文交错顺序:转换后块数量与顺序保持不变(文档推荐的 Image 1:/Image 2: 标签模式)。
|
||||||
|
func TestMessagesToChatReqMultiImage(t *testing.T) {
|
||||||
|
img := func(url string) any {
|
||||||
|
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
|
||||||
|
}
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "Image 1:"},
|
||||||
|
img("https://example.com/1.png"),
|
||||||
|
map[string]any{"type": "text", "text": "Image 2:"},
|
||||||
|
img("https://example.com/2.png"),
|
||||||
|
map[string]any{"type": "text", "text": "How are these images different?"},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
blocks := userContentBlocks(t, out)
|
||||||
|
if len(blocks) != 5 {
|
||||||
|
t.Fatalf("blocks len = %d: %s", len(blocks), out)
|
||||||
|
}
|
||||||
|
types := []string{}
|
||||||
|
for _, b := range blocks {
|
||||||
|
types = append(types, b.(map[string]any)["type"].(string))
|
||||||
|
}
|
||||||
|
want := []string{"text", "image_url", "text", "image_url", "text"}
|
||||||
|
for i := range want {
|
||||||
|
if types[i] != want[i] {
|
||||||
|
t.Fatalf("block[%d] type = %s, want %s: %v", i, types[i], want[i], types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 第二张图仍是第二张
|
||||||
|
if got := blockURL(t, blocks[3]); got != "https://example.com/2.png" {
|
||||||
|
t.Fatalf("image2 url = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatToMessagesReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "describe"},
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64," + testB64}},
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/b.png"}},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(out, &m)
|
||||||
|
msgs := m["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
if len(content) != 3 {
|
||||||
|
t.Fatalf("content len = %d: %s", len(content), out)
|
||||||
|
}
|
||||||
|
src1 := content[1].(map[string]any)["source"].(map[string]any)
|
||||||
|
if src1["type"] != "base64" || src1["media_type"] != "image/jpeg" || src1["data"] != testB64 {
|
||||||
|
t.Fatalf("base64 source = %v", src1)
|
||||||
|
}
|
||||||
|
src2 := content[2].(map[string]any)["source"].(map[string]any)
|
||||||
|
if src2["type"] != "url" || src2["url"] != "https://example.com/b.png" {
|
||||||
|
t.Fatalf("url source = %v", src2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesToChatReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"input": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "input_text", "text": "describe"},
|
||||||
|
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
blocks := userContentBlocks(t, out)
|
||||||
|
if len(blocks) != 2 {
|
||||||
|
t.Fatalf("blocks len = %d: %s", len(blocks), out)
|
||||||
|
}
|
||||||
|
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
|
||||||
|
t.Fatalf("image url = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatToResponsesReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "describe"},
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/c.png"}},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(out, &m)
|
||||||
|
var input []map[string]any
|
||||||
|
// 单条 user 消息项
|
||||||
|
switch v := m["input"].(type) {
|
||||||
|
case map[string]any:
|
||||||
|
input = []map[string]any{v}
|
||||||
|
case []any:
|
||||||
|
for _, it := range v {
|
||||||
|
input = append(input, it.(map[string]any))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := input[0]["content"].([]any)
|
||||||
|
if len(content) != 2 {
|
||||||
|
t.Fatalf("content len = %d: %s", len(content), out)
|
||||||
|
}
|
||||||
|
img := content[1].(map[string]any)
|
||||||
|
if img["type"] != "input_image" {
|
||||||
|
t.Fatalf("block type = %v", img["type"])
|
||||||
|
}
|
||||||
|
if img["image_url"] != "https://example.com/c.png" {
|
||||||
|
t.Fatalf("image_url = %v", img["image_url"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链式转换:responses→messages、messages→responses(经 chat 中转)也不丢图。
|
||||||
|
func TestResponsesToMessagesReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "claude-sonnet-5",
|
||||||
|
"input": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "input_text", "text": "describe"},
|
||||||
|
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoMessages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(out, &m)
|
||||||
|
msgs := m["messages"].([]any)
|
||||||
|
content := msgs[0].(map[string]any)["content"].([]any)
|
||||||
|
if len(content) != 2 {
|
||||||
|
t.Fatalf("content len = %d: %s", len(content), out)
|
||||||
|
}
|
||||||
|
src := content[1].(map[string]any)["source"].(map[string]any)
|
||||||
|
if src["type"] != "base64" || src["data"] != testB64 {
|
||||||
|
t.Fatalf("source = %v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesToResponsesReqImage(t *testing.T) {
|
||||||
|
in := mustJSON(t, map[string]any{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/d.png"}},
|
||||||
|
map[string]any{"type": "text", "text": "describe"},
|
||||||
|
}}},
|
||||||
|
})
|
||||||
|
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoResponses)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("convert: %v", err)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(out, &m)
|
||||||
|
var content []any
|
||||||
|
switch v := m["input"].(type) {
|
||||||
|
case map[string]any:
|
||||||
|
content = v["content"].([]any)
|
||||||
|
case []any:
|
||||||
|
content = v[0].(map[string]any)["content"].([]any)
|
||||||
|
}
|
||||||
|
if len(content) != 2 {
|
||||||
|
t.Fatalf("content len = %d: %s", len(content), out)
|
||||||
|
}
|
||||||
|
img := content[0].(map[string]any)
|
||||||
|
if img["type"] != "input_image" || img["image_url"] != "https://example.com/d.png" {
|
||||||
|
t.Fatalf("input_image = %v", img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ func chatMsgToAnthropic(m chatMsg) any {
|
|||||||
var url string
|
var url string
|
||||||
if iu, ok := b["image_url"].(map[string]any); ok {
|
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||||
url, _ = iu["url"].(string)
|
url, _ = iu["url"].(string)
|
||||||
|
} else if s, ok := b["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
}
|
}
|
||||||
if url != "" {
|
if url != "" {
|
||||||
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}})
|
blocks = append(blocks, anthropicImageBlock(url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,12 +231,18 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
||||||
var out []any
|
var out []any
|
||||||
var textParts []string
|
var textParts []string
|
||||||
|
var contentBlocks []any // text / image_url 块,保留原始顺序
|
||||||
var toolCalls []any
|
var toolCalls []any
|
||||||
for _, b := range blocks {
|
for _, b := range blocks {
|
||||||
switch b["type"] {
|
switch b["type"] {
|
||||||
case "text":
|
case "text":
|
||||||
if t, _ := b["text"].(string); t != "" {
|
if t, _ := b["text"].(string); t != "" {
|
||||||
textParts = append(textParts, t)
|
textParts = append(textParts, t)
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
if cb := chatImageBlock(b); cb != nil {
|
||||||
|
contentBlocks = append(contentBlocks, cb)
|
||||||
}
|
}
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
id, _ := b["id"].(string)
|
id, _ := b["id"].(string)
|
||||||
@@ -254,9 +262,19 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(textParts) > 0 || len(toolCalls) > 0 {
|
hasImage := false
|
||||||
|
for _, cb := range contentBlocks {
|
||||||
|
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||||||
|
hasImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
|
||||||
msg := map[string]any{"role": role}
|
msg := map[string]any{"role": role}
|
||||||
if len(textParts) > 0 {
|
switch {
|
||||||
|
case hasImage:
|
||||||
|
msg["content"] = contentBlocks
|
||||||
|
case len(textParts) > 0:
|
||||||
msg["content"] = strings.Join(textParts, "")
|
msg["content"] = strings.Join(textParts, "")
|
||||||
}
|
}
|
||||||
if len(toolCalls) > 0 {
|
if len(toolCalls) > 0 {
|
||||||
@@ -416,6 +434,58 @@ func intOrNil(p *int, def int) any {
|
|||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
|
||||||
|
func splitDataURL(url string) (media, data string, ok bool) {
|
||||||
|
if !strings.HasPrefix(url, "data:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
i := strings.Index(url, ";base64,")
|
||||||
|
if i < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return url[len("data:"):i], url[i+len(";base64,"):], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
|
||||||
|
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
|
||||||
|
func chatImageBlock(b map[string]any) any {
|
||||||
|
src, ok := b["source"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch src["type"] {
|
||||||
|
case "base64":
|
||||||
|
media, _ := src["media_type"].(string)
|
||||||
|
data, _ := src["data"].(string)
|
||||||
|
if data == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if media == "" {
|
||||||
|
media = "image/png"
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
|
||||||
|
case "url":
|
||||||
|
url, _ := src["url"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
|
||||||
|
// data URL → base64 source;http(s) URL → url source。
|
||||||
|
func anthropicImageBlock(url string) any {
|
||||||
|
if media, data, ok := splitDataURL(url); ok {
|
||||||
|
if media == "" {
|
||||||
|
media = "image/png"
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
|
||||||
|
}
|
||||||
|
|
||||||
func rawOrObject(raw json.RawMessage) any {
|
func rawOrObject(raw json.RawMessage) any {
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
|
|||||||
@@ -101,20 +101,82 @@ func responsesInputToChat(raw json.RawMessage) []any {
|
|||||||
out = append(out, map[string]any{"role": role, "content": content})
|
out = append(out, map[string]any{"role": role, "content": content})
|
||||||
} else if blocks, ok := item["content"].([]any); ok {
|
} else if blocks, ok := item["content"].([]any); ok {
|
||||||
var text []string
|
var text []string
|
||||||
|
var contentBlocks []any
|
||||||
for _, b := range blocks {
|
for _, b := range blocks {
|
||||||
if bm, ok := b.(map[string]any); ok {
|
bm, ok := b.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch bm["type"] {
|
||||||
|
case "input_text", "text":
|
||||||
if t, _ := bm["text"].(string); t != "" {
|
if t, _ := bm["text"].(string); t != "" {
|
||||||
text = append(text, t)
|
text = append(text, t)
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||||
|
}
|
||||||
|
case "input_image":
|
||||||
|
var url string
|
||||||
|
if s, ok := bm["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
|
} else if m, ok := bm["image_url"].(map[string]any); ok {
|
||||||
|
url, _ = m["url"].(string)
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
hasImage := false
|
||||||
|
for _, cb := range contentBlocks {
|
||||||
|
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||||||
|
hasImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasImage {
|
||||||
|
out = append(out, map[string]any{"role": role, "content": contentBlocks})
|
||||||
|
} else {
|
||||||
|
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
|
||||||
|
func chatContentToResponsesBlocks(content json.RawMessage) []any {
|
||||||
|
// 纯字符串 → 单个 input_text
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(content, &s) == nil && s != "" {
|
||||||
|
return []any{map[string]any{"type": "input_text", "text": s}}
|
||||||
|
}
|
||||||
|
// 数组 → 按块转换(text / image_url)
|
||||||
|
var arr []map[string]any
|
||||||
|
if json.Unmarshal(content, &arr) == nil && arr != nil {
|
||||||
|
var out []any
|
||||||
|
for _, b := range arr {
|
||||||
|
switch b["type"] {
|
||||||
|
case "text", "input_text":
|
||||||
|
if t, _ := b["text"].(string); t != "" {
|
||||||
|
out = append(out, map[string]any{"type": "input_text", "text": t})
|
||||||
|
}
|
||||||
|
case "image_url":
|
||||||
|
var url string
|
||||||
|
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||||
|
url, _ = iu["url"].(string)
|
||||||
|
} else if s, ok := b["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
out = append(out, map[string]any{"type": "input_image", "image_url": url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 请求:Chat → Responses
|
// 请求:Chat → Responses
|
||||||
|
|
||||||
@@ -170,10 +232,8 @@ func chatToResponsesReq(body []byte) ([]byte, error) {
|
|||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if s := str(m.Content); s != "" {
|
if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
|
||||||
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{
|
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
|
||||||
map[string]any{"type": "input_text", "text": s},
|
|
||||||
}})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,9 +412,10 @@ func (t *responsesToChat) line(line []byte) []byte {
|
|||||||
|
|
||||||
type chatToResponses struct {
|
type chatToResponses struct {
|
||||||
sseState
|
sseState
|
||||||
model string
|
model string
|
||||||
usage any
|
usage any
|
||||||
done bool
|
finishSeen bool
|
||||||
|
done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
|
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
|
||||||
@@ -425,6 +426,7 @@ func (t *chatToResponses) line(line []byte) []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
|
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
|
||||||
if !t.done {
|
if !t.done {
|
||||||
t.done = true
|
t.done = true
|
||||||
return eventLine("response.completed", map[string]any{
|
return eventLine("response.completed", map[string]any{
|
||||||
@@ -453,6 +455,9 @@ func (t *chatToResponses) line(line []byte) []byte {
|
|||||||
finish, _ = c0["finish_reason"].(string)
|
finish, _ = c0["finish_reason"].(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if finish != "" {
|
||||||
|
t.finishSeen = true
|
||||||
|
}
|
||||||
var out [][]byte
|
var out [][]byte
|
||||||
if role, _ := delta["role"].(string); role == "assistant" {
|
if role, _ := delta["role"].(string); role == "assistant" {
|
||||||
out = append(out, eventLine("response.created", map[string]any{
|
out = append(out, eventLine("response.created", map[string]any{
|
||||||
@@ -465,7 +470,8 @@ func (t *chatToResponses) line(line []byte) []byte {
|
|||||||
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
|
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if finish != "" && !t.done {
|
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
|
||||||
|
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
|
||||||
t.done = true
|
t.done = true
|
||||||
out = append(out, eventLine("response.completed", map[string]any{
|
out = append(out, eventLine("response.completed", map[string]any{
|
||||||
"type": "response.completed",
|
"type": "response.completed",
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -34,6 +36,76 @@ type Gateway struct {
|
|||||||
lim *ratelimit.Limiter
|
lim *ratelimit.Limiter
|
||||||
userRPS int
|
userRPS int
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
|
||||||
|
policyMu sync.Mutex
|
||||||
|
policy modelPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPolicy 全局模型限制策略(来自系统配置,短时缓存)。
|
||||||
|
type modelPolicy struct {
|
||||||
|
allow []string
|
||||||
|
deny []string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelPolicyTTL = 5 * time.Second
|
||||||
|
|
||||||
|
// ResetModelPolicy 清空全局模型限制缓存(系统配置保存后调用)。
|
||||||
|
func (g *Gateway) ResetModelPolicy() {
|
||||||
|
g.policyMu.Lock()
|
||||||
|
g.policy = modelPolicy{}
|
||||||
|
g.policyMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalModelRestrictions 读取全局模型允许/禁止列表(缓存 30s)。
|
||||||
|
func (g *Gateway) globalModelRestrictions() (allow, deny []string) {
|
||||||
|
g.policyMu.Lock()
|
||||||
|
defer g.policyMu.Unlock()
|
||||||
|
if time.Since(g.policy.at) < modelPolicyTTL {
|
||||||
|
return g.policy.allow, g.policy.deny
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &allow)
|
||||||
|
raw = ""
|
||||||
|
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &deny)
|
||||||
|
g.policy = modelPolicy{allow: allow, deny: deny, at: time.Now()}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkModelAllowed 模型访问控制:用户级 > 全局。
|
||||||
|
// 1. 用户禁止列表命中 → 拒绝
|
||||||
|
// 2. 用户允许列表非空 → 仅列表内可访问(不再看全局)
|
||||||
|
// 3. 全局禁止命中 → 拒绝
|
||||||
|
// 4. 全局允许列表非空 → 仅列表内可访问
|
||||||
|
func (g *Gateway) checkModelAllowed(u *store.User, model string) bool {
|
||||||
|
if model == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if contains(u.DeniedModels, model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(u.AllowedModels) > 0 {
|
||||||
|
return contains(u.AllowedModels, model)
|
||||||
|
}
|
||||||
|
allow, deny := g.globalModelRestrictions()
|
||||||
|
if contains(deny, model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(allow) > 0 {
|
||||||
|
return contains(allow, model)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway {
|
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway {
|
||||||
@@ -148,8 +220,8 @@ func (g *Gateway) models(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
|
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局)。
|
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局;携带模型映射)。
|
||||||
func (g *Gateway) candidateChannels(model string) []*store.Channel {
|
func (g *Gateway) candidateChannels(model string) []channel.Candidate {
|
||||||
return g.ch.Candidates(model)
|
return g.ch.Candidates(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,16 +251,17 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
|||||||
func upstreamPath(proto string) string {
|
func upstreamPath(proto string) string {
|
||||||
switch proto {
|
switch proto {
|
||||||
case convert.ProtoMessages:
|
case convert.ProtoMessages:
|
||||||
return "/v1/messages"
|
return "/messages"
|
||||||
case convert.ProtoResponses:
|
case convert.ProtoResponses:
|
||||||
return "/v1/responses"
|
return "/responses"
|
||||||
default:
|
default:
|
||||||
return "/v1/chat/completions"
|
return "/chat/completions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamPlan 描述一次代理请求的上游访问方式。
|
// upstreamPlan 描述一次代理请求的上游访问方式。
|
||||||
type upstreamPlan struct {
|
type upstreamPlan struct {
|
||||||
|
proto string // 上游协议(用于分协议 base_url)
|
||||||
path string // 上游路径
|
path string // 上游路径
|
||||||
body []byte // 已转换的请求体
|
body []byte // 已转换的请求体
|
||||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||||
@@ -213,13 +286,14 @@ func conversionTarget(formats []string, clientProto string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换。
|
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换;
|
||||||
func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstreamPlan, error) {
|
// 应用模型名称映射(upstream_model)。
|
||||||
|
func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstreamModel string) (*upstreamPlan, error) {
|
||||||
target := conversionTarget(ch.FormatsEffective(), clientProto)
|
target := conversionTarget(ch.FormatsEffective(), clientProto)
|
||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
|
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
|
||||||
}
|
}
|
||||||
plan := &upstreamPlan{path: upstreamPath(target), body: body}
|
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
|
||||||
if target != clientProto {
|
if target != clientProto {
|
||||||
converted, err := convert.ConvertRequest(body, clientProto, target)
|
converted, err := convert.ConvertRequest(body, clientProto, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,7 +303,26 @@ func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstr
|
|||||||
plan.lineConv = convert.NewStreamTransformer(target, clientProto)
|
plan.lineConv = convert.NewStreamTransformer(target, clientProto)
|
||||||
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
|
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
|
||||||
}
|
}
|
||||||
|
// 模型名称映射:把请求体 model 字段改写为渠道侧的 upstream_model
|
||||||
|
if upstreamModel != "" {
|
||||||
|
if out, err := rewriteModel(plan.body, upstreamModel); err == nil {
|
||||||
|
plan.body = out
|
||||||
|
}
|
||||||
|
}
|
||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rewriteModel 改写请求体中的 model 字段(三种协议 model 都在顶层)。
|
||||||
|
func rewriteModel(body []byte, model string) ([]byte, error) {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
if cur, _ := m["model"].(string); cur == model {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
m["model"] = model
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
var errNoChannel = errors.New("no available channel")
|
var errNoChannel = errors.New("no available channel")
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoChat)
|
c.Set("protocol", convert.ProtoChat)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cands := g.candidateChannels(br.Model)
|
cands := g.candidateChannels(br.Model)
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
@@ -51,6 +55,10 @@ func (g *Gateway) responses(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoResponses)
|
c.Set("protocol", convert.ProtoResponses)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cands := g.candidateChannels(br.Model)
|
cands := g.candidateChannels(br.Model)
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
@@ -79,6 +87,10 @@ func (g *Gateway) messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoMessages)
|
c.Set("protocol", convert.ProtoMessages)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cands := g.candidateChannels(br.Model)
|
cands := g.candidateChannels(br.Model)
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/openteam/server/internal/channel"
|
||||||
|
"github.com/openteam/server/internal/pkg/tokenizer"
|
||||||
"github.com/openteam/server/internal/store"
|
"github.com/openteam/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,24 +40,119 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
// 中断请求输入文本(token 估算用,仅存文本不立即分词)
|
||||||
|
c.Set("est_input_text", requestText(body))
|
||||||
br := &bodyReq{}
|
br := &bodyReq{}
|
||||||
_ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发
|
_ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发
|
||||||
return br, body, nil
|
return br, body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upstreamURL 组装上游地址:base_url + 路径。
|
// requestText 提取请求体中的用户输入文本(chat/messages 的 content、responses 的 input/instructions),
|
||||||
// 兼容用户填完整 base(含 /v1):去掉尾部 /v1,避免与请求路径重复。
|
// 用于中断时估算输入 token。
|
||||||
func upstreamURL(ch *store.Channel, path string) string {
|
func requestText(body []byte) string {
|
||||||
base := strings.TrimRight(ch.BaseURL, "/")
|
var m map[string]any
|
||||||
return strings.TrimSuffix(base, "/v1") + path
|
if json.Unmarshal(body, &m) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
add := func(s string) {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s, ok := m["instructions"].(string); ok {
|
||||||
|
add(s)
|
||||||
|
}
|
||||||
|
if s, ok := m["system"].(string); ok {
|
||||||
|
add(s)
|
||||||
|
}
|
||||||
|
switch input := m["input"].(type) {
|
||||||
|
case string:
|
||||||
|
add(input)
|
||||||
|
case []any:
|
||||||
|
for _, it := range input {
|
||||||
|
if im, ok := it.(map[string]any); ok {
|
||||||
|
if s, ok := im["content"].(string); ok {
|
||||||
|
add(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msgs, ok := m["messages"].([]any); ok {
|
||||||
|
for _, msg := range msgs {
|
||||||
|
mm, ok := msg.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch c := mm["content"].(type) {
|
||||||
|
case string:
|
||||||
|
add(c)
|
||||||
|
case []any:
|
||||||
|
for _, b := range c {
|
||||||
|
if bm, ok := b.(map[string]any); ok {
|
||||||
|
if s, ok := bm["text"].(string); ok {
|
||||||
|
add(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseContentText 提取一条 SSE data 行中的内容文本(chat delta.content / responses delta / messages delta.text)。
|
||||||
|
func sseContentText(line []byte) string {
|
||||||
|
s := string(line)
|
||||||
|
if strings.HasPrefix(s, "data: ") {
|
||||||
|
s = strings.TrimPrefix(s, "data: ")
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || s == "[DONE]" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal([]byte(s), &m) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// responses output_text.delta: {"delta":"..."}
|
||||||
|
if d, ok := m["delta"].(string); ok {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
// messages content_block_delta: {"delta":{"text":"..."}}
|
||||||
|
if dm, ok := m["delta"].(map[string]any); ok {
|
||||||
|
if t, ok := dm["text"].(string); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// chat chunk: {"choices":[{"delta":{"content":"..."}}]}(GLM 思考模型另有 reasoning_content)
|
||||||
|
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
|
||||||
|
if c0, ok := choices[0].(map[string]any); ok {
|
||||||
|
if delta, ok := c0["delta"].(map[string]any); ok {
|
||||||
|
if t, ok := delta["content"].(string); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if t, ok := delta["reasoning_content"].(string); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。
|
||||||
|
func upstreamURL(ch *store.Channel, proto, path string) string {
|
||||||
|
return ch.UpstreamURL(proto, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
||||||
func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto string, body []byte, stream bool, sink *usageSink) {
|
func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto string, body []byte, stream bool, sink *usageSink) {
|
||||||
var lastStatus = http.StatusBadGateway
|
var lastStatus = http.StatusBadGateway
|
||||||
var lastBody = []byte("all upstream channels failed")
|
var lastBody = []byte("all upstream channels failed")
|
||||||
for _, ch := range cands {
|
for _, cand := range cands {
|
||||||
plan, err := prepareUpstream(ch, clientProto, body)
|
ch := cand.Channel
|
||||||
|
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
||||||
continue
|
continue
|
||||||
@@ -93,7 +190,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
|||||||
|
|
||||||
upBody := plan.body
|
upBody := plan.body
|
||||||
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
|
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
|
||||||
if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
if stream && plan.path == "/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
if json.Unmarshal(upBody, &m) == nil {
|
if json.Unmarshal(upBody, &m) == nil {
|
||||||
m["stream_options"] = map[string]any{"include_usage": true}
|
m["stream_options"] = map[string]any{"include_usage": true}
|
||||||
@@ -105,7 +202,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||||
}
|
}
|
||||||
@@ -115,7 +212,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
|||||||
if ua := c.GetHeader("User-Agent"); ua != "" {
|
if ua := c.GetHeader("User-Agent"); ua != "" {
|
||||||
req.Header.Set("User-Agent", ua)
|
req.Header.Set("User-Agent", ua)
|
||||||
}
|
}
|
||||||
if plan.path == "/v1/messages" {
|
if plan.path == "/messages" {
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
}
|
}
|
||||||
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
|
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
|
||||||
@@ -192,10 +289,14 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
|
|||||||
}
|
}
|
||||||
if out != nil {
|
if out != nil {
|
||||||
if _, werr := w.Write(out); werr != nil {
|
if _, werr := w.Write(out); werr != nil {
|
||||||
g.recordError(c, ch, nil, start, "client_disconnect")
|
// 客户端意外断开:按已生成部分收费(canceled)
|
||||||
|
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
if sink != nil {
|
||||||
|
sink.outputText += sseContentText(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if usageRaw := scanUsage(line); usageRaw != nil && sink != nil {
|
if usageRaw := scanUsage(line); usageRaw != nil && sink != nil {
|
||||||
sink.push(usageRaw)
|
sink.push(usageRaw)
|
||||||
@@ -205,7 +306,8 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
|
|||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||||
} else if c.Request.Context().Err() != nil {
|
} else if c.Request.Context().Err() != nil {
|
||||||
g.recordError(c, ch, nil, start, "client_disconnect")
|
// 客户端意外断开:按已生成部分收费(canceled)
|
||||||
|
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||||
} else {
|
} else {
|
||||||
g.recordError(c, ch, nil, start, "stream_read_error")
|
g.recordError(c, ch, nil, start, "stream_read_error")
|
||||||
}
|
}
|
||||||
@@ -336,8 +438,10 @@ func (s *sseScanner) Next() ([]byte, error) {
|
|||||||
// 记账
|
// 记账
|
||||||
|
|
||||||
// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。
|
// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。
|
||||||
|
// outputText 累积已转发内容文本,用于流式中断时按 tiktoken 估算输出 token。
|
||||||
type usageSink struct {
|
type usageSink struct {
|
||||||
us usageShape
|
us usageShape
|
||||||
|
outputText string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *usageSink) push(raw json.RawMessage) {
|
func (u *usageSink) push(raw json.RawMessage) {
|
||||||
@@ -379,9 +483,11 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
trace, _ := c.Get(CtxTrace)
|
trace, _ := c.Get(CtxTrace)
|
||||||
|
|
||||||
var us usageShape
|
var us usageShape
|
||||||
|
var sink *usageSink
|
||||||
if h, ok := c.Get("usage_raw"); ok {
|
if h, ok := c.Get("usage_raw"); ok {
|
||||||
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil {
|
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil {
|
||||||
us = holder.sink.Shape()
|
us = holder.sink.Shape()
|
||||||
|
sink = holder.sink
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +499,20 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
modelName, _ := c.Get("model_name")
|
modelName, _ := c.Get("model_name")
|
||||||
mn, _ := modelName.(string)
|
mn, _ := modelName.(string)
|
||||||
|
|
||||||
|
// 流式中断(canceled):上游最终 usage 可能未返回,按已生成内容用 tiktoken 估算
|
||||||
|
if status == store.UsageStatusCanceled {
|
||||||
|
if in == 0 {
|
||||||
|
if est, ok := c.Get("est_input_text"); ok {
|
||||||
|
if v, ok2 := est.(string); ok2 {
|
||||||
|
in = int64(tokenizer.Count(v, mn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == 0 && sink != nil && sink.outputText != "" {
|
||||||
|
out = int64(tokenizer.Count(sink.outputText, mn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var model store.Model
|
var model store.Model
|
||||||
var cost float64
|
var cost float64
|
||||||
var modelID uint64
|
var modelID uint64
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
||||||
const (
|
const (
|
||||||
@@ -52,6 +56,8 @@ type User struct {
|
|||||||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||||
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||||||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||||
|
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
|
||||||
|
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
|
||||||
InviteCode *string `json:"invite_code,omitempty"`
|
InviteCode *string `json:"invite_code,omitempty"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -80,9 +86,10 @@ type Channel struct {
|
|||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||||||
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
|
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
|
||||||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||||||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"` // 分协议 base_url 覆盖(chat/responses/messages)
|
||||||
|
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||||||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||||
@@ -108,6 +115,32 @@ func (c *Channel) FormatsEffective() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// versionSegRe 匹配末尾版本前缀,如 /v1、/v2、/v4。
|
||||||
|
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
|
||||||
|
|
||||||
|
// UpstreamURL 按协议选 base_url(分协议覆盖优先),再按版本前缀拼资源路径(path 不含 /v1)。
|
||||||
|
// - proto 有 BaseURLs 覆盖时用覆盖值,否则用主 BaseURL
|
||||||
|
// - base 已以资源路径结尾 → 原样
|
||||||
|
// - base 含版本前缀(如 /v1、/v4) → base + path
|
||||||
|
// - 否则 → base + /v1 + path(默认补 OpenAI/Anthropic 的 /v1)
|
||||||
|
func (c *Channel) UpstreamURL(proto, path string) string {
|
||||||
|
base := c.BaseURL
|
||||||
|
if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" {
|
||||||
|
base = c.BaseURLs[proto]
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
if base == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, path) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if versionSegRe.MatchString(base) {
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
return base + "/v1" + path
|
||||||
|
}
|
||||||
|
|
||||||
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
||||||
type Model struct {
|
type Model struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
@@ -198,6 +231,16 @@ type BalanceLog struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passkey WebAuthn 凭据(passkey 绑定/登录)
|
||||||
|
type Passkey struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
CredentialID []byte `gorm:"size:255;not null" json:"-"` // credential.ID
|
||||||
|
Credential []byte `gorm:"type:blob;not null" json:"-"` // json.Marshal(webauthn.Credential)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// SystemConfig 系统配置(PLANNING §6.9)
|
// SystemConfig 系统配置(PLANNING §6.9)
|
||||||
type SystemConfig struct {
|
type SystemConfig struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
@@ -216,6 +259,7 @@ func AllModels() []any {
|
|||||||
&UsageDaily{},
|
&UsageDaily{},
|
||||||
&RechargeOrder{},
|
&RechargeOrder{},
|
||||||
&BalanceLog{},
|
&BalanceLog{},
|
||||||
|
&Passkey{},
|
||||||
&SystemConfig{},
|
&SystemConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestChannelUpstreamURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
base, path, want string
|
||||||
|
}{
|
||||||
|
// 无版本前缀 → 默认补 /v1(OpenAI 纯域名)
|
||||||
|
{"https://api.openai.com", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||||
|
// 已含 /v1 → 直接拼资源路径
|
||||||
|
{"https://api.openai.com/v1", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||||
|
{"https://api.anthropic.com/v1", "/messages", "https://api.anthropic.com/v1/messages"},
|
||||||
|
// 自定义版本前缀(如 BigModel 的 /v4) → 直接拼资源路径
|
||||||
|
{"https://open.bigmodel.cn/api/paas/v4", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"},
|
||||||
|
{"https://open.bigmodel.cn/api/paas/v4", "/models", "https://open.bigmodel.cn/api/paas/v4/models"},
|
||||||
|
// 本地 mock:无版本前缀补 /v1
|
||||||
|
{"http://localhost:9000", "/chat/completions", "http://localhost:9000/v1/chat/completions"},
|
||||||
|
{"http://localhost:9000/v1", "/models", "http://localhost:9000/v1/models"},
|
||||||
|
// 完整端点原样
|
||||||
|
{"https://api.openai.com/v1/chat/completions", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||||
|
// 尾斜杠
|
||||||
|
{"https://api.openai.com/v1/", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
ch := &Channel{BaseURL: c.base}
|
||||||
|
got := ch.UpstreamURL("", c.path)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.base, c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分协议 base_url 覆盖
|
||||||
|
ch := &Channel{
|
||||||
|
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
BaseURLs: map[string]string{"responses": "https://resp.example.com/v4", "messages": "https://msg.example.com/v1"},
|
||||||
|
}
|
||||||
|
cases2 := []struct{ proto, path, want string }{
|
||||||
|
{"chat", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"}, // 用主 base
|
||||||
|
{"responses", "/responses", "https://resp.example.com/v4/responses"}, // 用覆盖 base
|
||||||
|
{"messages", "/messages", "https://msg.example.com/v1/messages"}, // 用覆盖 base
|
||||||
|
{"", "/models", "https://open.bigmodel.cn/api/paas/v4/models"}, // 空协议用主 base
|
||||||
|
}
|
||||||
|
for _, c := range cases2 {
|
||||||
|
got := ch.UpstreamURL(c.proto, c.path)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.proto, c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,8 @@ func (r *Recorder) flush(logs []*store.UsageLog) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
if l.Status != store.UsageStatusSuccess || l.Cost <= 0 {
|
// 计费范围:success(正常完成)与 canceled(流式中断,按已生成部分收费)
|
||||||
|
if (l.Status != store.UsageStatusSuccess && l.Status != store.UsageStatusCanceled) || l.Cost <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 扣余额(余额可为负:流式请求不中断;后续请求被拒)
|
// 扣余额(余额可为负:流式请求不中断;后续请求被拒)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { PhList } from '@phosphor-icons/vue'
|
import {
|
||||||
|
PhList, PhGauge, PhSquaresFour, PhKey, PhChartLine, PhChartBar, PhGear,
|
||||||
|
PhShieldCheck, PhArrowLeft, PhGlobe, PhTag, PhUsers, PhSliders,
|
||||||
|
} from '@phosphor-icons/vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { fmtMoney } from '@/lib/format'
|
import { fmtMoney } from '@/lib/format'
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||||
@@ -9,6 +12,7 @@ import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
|||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
label: string
|
||||||
|
icon: unknown
|
||||||
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
|
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,30 +31,30 @@ const sections = computed(() => {
|
|||||||
{
|
{
|
||||||
title: '管理后台',
|
title: '管理后台',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/overview', label: '总览' },
|
{ to: '/admin/overview', label: '总览', icon: PhSquaresFour },
|
||||||
{ to: '/admin/channels', label: '渠道管理' },
|
{ to: '/admin/channels', label: '渠道管理', icon: PhGlobe },
|
||||||
{ to: '/admin/models', label: '模型定价' },
|
{ to: '/admin/models', label: '模型定价', icon: PhTag },
|
||||||
{ to: '/admin/users', label: '用户管理' },
|
{ to: '/admin/users', label: '用户管理', icon: PhUsers },
|
||||||
{ to: '/admin/usage', label: '用量统计' },
|
{ to: '/admin/usage', label: '用量统计', icon: PhChartBar },
|
||||||
{ to: '/admin/config', label: '系统配置' },
|
{ to: '/admin/config', label: '系统配置', icon: PhSliders },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', jump: true }] },
|
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', icon: PhArrowLeft, jump: true }] },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
const user: { title: string; items: NavItem[] }[] = [
|
const user: { title: string; items: NavItem[] }[] = [
|
||||||
{
|
{
|
||||||
title: '管理',
|
title: '管理',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/console/dashboard', label: '仪表盘' },
|
{ to: '/console/dashboard', label: '仪表盘', icon: PhGauge },
|
||||||
{ to: '/console/keys', label: 'API 密钥' },
|
{ to: '/console/keys', label: 'API 密钥', icon: PhKey },
|
||||||
{ to: '/console/usage', label: '用量明细' },
|
{ to: '/console/usage', label: '用量明细', icon: PhChartLine },
|
||||||
{ to: '/console/settings', label: '账户设置' },
|
{ to: '/console/settings', label: '账户设置', icon: PhGear },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (auth.isAdmin) {
|
if (auth.isAdmin) {
|
||||||
user[0].items.push({ to: '/admin/overview', label: '管理', jump: true })
|
user[0].items.push({ to: '/admin/overview', label: '管理', icon: PhShieldCheck, jump: true })
|
||||||
}
|
}
|
||||||
return user
|
return user
|
||||||
})
|
})
|
||||||
@@ -98,7 +102,10 @@ async function logout() {
|
|||||||
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
|
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
|
||||||
@click="navTo"
|
@click="navTo"
|
||||||
>
|
>
|
||||||
<span>{{ n.label }}</span>
|
<span class="inline-flex min-w-0 items-center gap-2">
|
||||||
|
<component :is="n.icon" :size="15" :weight="n.jump ? 'regular' : 'bold'" class="shrink-0" />
|
||||||
|
<span class="truncate">{{ n.label }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="n.jump" class="opacity-60" aria-hidden="true">→</span>
|
<span v-if="n.jump" class="opacity-60" aria-hidden="true">→</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -110,24 +117,24 @@ async function logout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
|
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
|
||||||
<header class="flex h-14 items-center justify-between border-b border-edge px-4 md:px-6">
|
<header class="flex h-14 items-center justify-between gap-2 border-b border-edge px-4 md:px-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="rounded-md p-1.5 text-muted hover:bg-surface2 hover:text-ink md:hidden"
|
class="shrink-0 rounded-md p-1.5 text-muted hover:bg-surface2 hover:text-ink md:hidden"
|
||||||
aria-label="打开菜单"
|
aria-label="打开菜单"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<PhList :size="20" />
|
<PhList :size="20" />
|
||||||
</button>
|
</button>
|
||||||
<span class="font-mono text-xs text-muted">{{ auth.user?.username }}</span>
|
<span class="truncate font-mono text-xs text-muted">{{ auth.user?.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex shrink-0 items-center gap-1.5 md:gap-3">
|
||||||
<span class="mono-num rounded-md border border-edge bg-surface px-2.5 py-1 text-xs text-accent">
|
<span class="mono-num rounded-md border border-edge bg-surface px-2 py-1 text-xs text-accent sm:px-2.5">
|
||||||
余额 {{ balance }}
|
<span class="hidden sm:inline">余额 </span>{{ balance }}
|
||||||
</span>
|
</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-2 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink"
|
class="rounded-md px-1.5 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink md:px-2"
|
||||||
@click="logout"
|
@click="logout"
|
||||||
>
|
>
|
||||||
退出
|
退出
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const toast = useToastStore()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed right-4 bottom-4 z-[80] flex w-80 flex-col gap-2" aria-live="polite">
|
<div class="fixed right-4 bottom-4 z-[80] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2" aria-live="polite">
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
enter-active-class="transition-all duration-200"
|
enter-active-class="transition-all duration-200"
|
||||||
enter-from-class="translate-y-1 opacity-0"
|
enter-from-class="translate-y-1 opacity-0"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// copyText 复制文本到剪贴板。
|
||||||
|
// 优先用 Clipboard API(安全上下文 HTTPS/localhost);HTTP 环境回退到 execCommand('copy')。
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
// 1. Clipboard API
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
/* 权限/焦点问题,回退 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. execCommand 兜底(非安全上下文可用)
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
ta.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.focus()
|
||||||
|
ta.select()
|
||||||
|
ta.setSelectionRange(0, text.length)
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,17 @@ export const PROTOCOL_NAMES: Record<string, string> = {
|
|||||||
messages: 'Anthropic Messages',
|
messages: 'Anthropic Messages',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渠道表格用的短标识
|
||||||
|
export const PROTOCOL_SHORT: Record<string, string> = {
|
||||||
|
chat: 'chat/completions',
|
||||||
|
responses: 'responses',
|
||||||
|
messages: 'messages',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function protocolShort(p: string): string {
|
||||||
|
return PROTOCOL_SHORT[p] ?? p
|
||||||
|
}
|
||||||
|
|
||||||
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
|
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: 'chat', label: 'OpenAI Chat Completions' },
|
{ value: 'chat', label: 'OpenAI Chat Completions' },
|
||||||
{ value: 'responses', label: 'OpenAI Responses API' },
|
{ value: 'responses', label: 'OpenAI Responses API' },
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// WebAuthn(passkey)浏览器侧工具:选项编解码 + 凭据序列化。
|
||||||
|
// go-webauthn 返回的 challenge/id 为 base64url 字符串,浏览器需要 ArrayBuffer;
|
||||||
|
// 回调的 credential 需要把 ArrayBuffer 字段转回 base64url。
|
||||||
|
|
||||||
|
export function bufToB64url(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf)
|
||||||
|
let s = ''
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b)
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64urlToBuf(s: string): ArrayBuffer {
|
||||||
|
const t = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const pad = t.length % 4 === 0 ? '' : '='.repeat(4 - (t.length % 4))
|
||||||
|
const bin = atob(t + pad)
|
||||||
|
const bytes = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialId {
|
||||||
|
type?: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareIds(list?: CredentialId[]): { type?: string; id: ArrayBuffer }[] | undefined {
|
||||||
|
if (!list) return undefined
|
||||||
|
return list.map((c) => ({ ...c, id: b64urlToBuf(c.id) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerPasskey 调用 navigator.credentials.create,返回可提交后端的 JSON。
|
||||||
|
export async function registerPasskey(options: Record<string, any>): Promise<Record<string, any>> {
|
||||||
|
const pk: Record<string, any> = { ...options.publicKey }
|
||||||
|
pk.challenge = b64urlToBuf(pk.challenge)
|
||||||
|
if (pk.user?.id) pk.user = { ...pk.user, id: b64urlToBuf(pk.user.id) }
|
||||||
|
pk.excludeCredentials = prepareIds(pk.excludeCredentials)
|
||||||
|
|
||||||
|
const cred = (await navigator.credentials.create({ publicKey: pk as PublicKeyCredentialCreationOptions })) as PublicKeyCredential
|
||||||
|
const resp = cred.response as AuthenticatorAttestationResponse
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64url(resp.clientDataJSON),
|
||||||
|
attestationObject: bufToB64url(resp.attestationObject),
|
||||||
|
transports: (resp as unknown as { getTransports?: () => string[] }).getTransports?.() ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginPasskey 调用 navigator.credentials.get,返回可提交后端的 JSON。
|
||||||
|
export async function loginPasskey(options: Record<string, any>): Promise<Record<string, any>> {
|
||||||
|
const pk: Record<string, any> = { ...options.publicKey }
|
||||||
|
pk.challenge = b64urlToBuf(pk.challenge)
|
||||||
|
pk.allowCredentials = prepareIds(pk.allowCredentials)
|
||||||
|
|
||||||
|
const cred = (await navigator.credentials.get({ publicKey: pk as PublicKeyCredentialRequestOptions })) as PublicKeyCredential
|
||||||
|
const resp = cred.response as AuthenticatorAssertionResponse
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64url(resp.clientDataJSON),
|
||||||
|
authenticatorData: bufToB64url(resp.authenticatorData),
|
||||||
|
signature: bufToB64url(resp.signature),
|
||||||
|
userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWebAuthnSupported 是否处于安全上下文(passkey 需要 HTTPS 或 localhost)。
|
||||||
|
export function isWebAuthnSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && !!navigator.credentials && window.isSecureContext
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async login(username: string, password: string) {
|
async login(username: string, password: string) {
|
||||||
const { data } = await http.post('/auth/login', { username, password })
|
const { data } = await http.post('/auth/login', { username, password })
|
||||||
const d = data.data as { access_token: string; user: User }
|
this.setSession(data.data)
|
||||||
|
},
|
||||||
|
setSession(d: { access_token: string; user: User }) {
|
||||||
this.accessToken = d.access_token
|
this.accessToken = d.access_token
|
||||||
this.user = d.user
|
this.user = d.user
|
||||||
localStorage.setItem('ot_access', d.access_token)
|
localStorage.setItem('ot_access', d.access_token)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface User {
|
|||||||
role: 'user' | 'admin'
|
role: 'user' | 'admin'
|
||||||
balance: number
|
balance: number
|
||||||
status: string
|
status: string
|
||||||
|
allowed_models?: string[] | null
|
||||||
|
denied_models?: string[] | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ export interface Channel {
|
|||||||
provider: 'openai' | 'anthropic' | 'compatible'
|
provider: 'openai' | 'anthropic' | 'compatible'
|
||||||
formats: string[] // chat | responses | messages
|
formats: string[] // chat | responses | messages
|
||||||
base_url: string
|
base_url: string
|
||||||
|
base_urls?: Record<string, string> | null // 分协议 base_url 覆盖
|
||||||
api_key_masked: string
|
api_key_masked: string
|
||||||
weight: number
|
weight: number
|
||||||
priority: number
|
priority: number
|
||||||
@@ -37,6 +40,14 @@ export interface Channel {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelModelMapping {
|
||||||
|
id: number
|
||||||
|
model_id: number
|
||||||
|
model_name: string
|
||||||
|
upstream_model: string
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelBinding {
|
export interface ModelBinding {
|
||||||
id: number
|
id: number
|
||||||
channel_id: number
|
channel_id: number
|
||||||
@@ -55,6 +66,16 @@ export interface Model {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
sort: number
|
sort: number
|
||||||
channels: ModelBinding[]
|
channels: ModelBinding[]
|
||||||
|
used?: boolean
|
||||||
|
needs_pricing?: boolean
|
||||||
|
denied?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
total: number
|
||||||
|
unpriced: number
|
||||||
|
missing: { channel: string; model_id: number }[]
|
||||||
|
denied_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageLog {
|
export interface UsageLog {
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ const auth = useAuthStore()
|
|||||||
<img src="/favicon.svg" alt="" class="size-5" />
|
<img src="/favicon.svg" alt="" class="size-5" />
|
||||||
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex items-center gap-2">
|
<nav class="flex items-center gap-1.5 sm:gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<router-link
|
<router-link
|
||||||
v-if="!auth.isAuthed"
|
v-if="!auth.isAuthed"
|
||||||
to="/login"
|
to="/login"
|
||||||
class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink"
|
class="rounded-md px-2.5 py-1.5 text-sm text-muted transition hover:text-ink sm:px-3"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-muted transition hover:text-ink">
|
<router-link v-else to="/console/dashboard" class="rounded-md px-2.5 py-1.5 text-sm text-muted transition hover:text-ink sm:px-3">
|
||||||
控制台
|
控制台
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="!auth.isAuthed"
|
v-if="!auth.isAuthed"
|
||||||
to="/register"
|
to="/register"
|
||||||
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong"
|
class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong sm:px-3.5"
|
||||||
>
|
>
|
||||||
免费注册
|
免费注册
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -42,7 +42,7 @@ const auth = useAuthStore()
|
|||||||
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
|
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
|
||||||
<div class="max-w-xl">
|
<div class="max-w-xl">
|
||||||
<p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
|
<p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
|
||||||
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
|
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
||||||
一个 Key,调用所有主流模型
|
一个 Key,调用所有主流模型
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-5 max-w-md text-base leading-relaxed text-muted">
|
<p class="mt-5 max-w-md text-base leading-relaxed text-muted">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { PhFingerprint } from '@phosphor-icons/vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
|
import { loginPasskey, isWebAuthnSupported } from '@/lib/webauthn'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||||
@@ -16,6 +18,7 @@ const toast = useToastStore()
|
|||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const pkLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -33,6 +36,34 @@ async function submit() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function passkeyLogin() {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/webauthn/login/begin', {
|
||||||
|
username: username.value || undefined,
|
||||||
|
})
|
||||||
|
const credential = await loginPasskey(data.data.assertion)
|
||||||
|
const resp = await http.post('/webauthn/login/complete', {
|
||||||
|
challenge: data.data.challenge,
|
||||||
|
credential,
|
||||||
|
user_id: data.data.user_id ?? 0,
|
||||||
|
})
|
||||||
|
auth.setSession(resp.data.data)
|
||||||
|
toast.ok('登录成功')
|
||||||
|
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errMsg(e)
|
||||||
|
} finally {
|
||||||
|
pkLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,6 +83,15 @@ async function submit() {
|
|||||||
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
||||||
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
||||||
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
||||||
|
<div class="flex items-center gap-3 py-1">
|
||||||
|
<div class="h-px flex-1 bg-edge" />
|
||||||
|
<span class="text-xs text-muted">或</span>
|
||||||
|
<div class="h-px flex-1 bg-edge" />
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" class="w-full" :loading="pkLoading" type="button" @click="passkeyLogin">
|
||||||
|
<PhFingerprint :size="15" />
|
||||||
|
使用 Passkey 登录
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-5 text-center text-sm text-muted">
|
<p class="mt-5 text-center text-sm text-muted">
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { PhArrowsClockwise, PhPlus, PhX } from '@phosphor-icons/vue'
|
||||||
|
import { http, errMsg } from '@/api/client'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import type { Channel, ChannelModelMapping } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ channel: Channel }>()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const mappings = ref<ChannelModelMapping[]>([])
|
||||||
|
const remote = ref<string[]>([])
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const addForm = reactive({ custom_name: '', upstream_model: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/admin/channels/${props.channel.id}/models`)
|
||||||
|
mappings.value = data.data.items
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemote() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
|
||||||
|
remote.value = data.data.items
|
||||||
|
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
||||||
|
selected.value = remote.value.filter((r) => bound.has(r))
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSelected() {
|
||||||
|
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
||||||
|
let added = 0
|
||||||
|
for (const name of selected.value) {
|
||||||
|
if (bound.has(name)) continue
|
||||||
|
try {
|
||||||
|
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
|
||||||
|
added++
|
||||||
|
} catch {
|
||||||
|
/* 单个失败不中断 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addManual() {
|
||||||
|
if (!addForm.upstream_model.trim()) return
|
||||||
|
try {
|
||||||
|
await http.post(`/admin/channels/${props.channel.id}/models`, {
|
||||||
|
upstream_model: addForm.upstream_model.trim(),
|
||||||
|
custom_name: addForm.custom_name.trim(),
|
||||||
|
})
|
||||||
|
toast.ok('已添加')
|
||||||
|
addForm.custom_name = ''
|
||||||
|
addForm.upstream_model = ''
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUpstream(b: ChannelModelMapping) {
|
||||||
|
try {
|
||||||
|
await http.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
|
||||||
|
upstream_model: b.upstream_model,
|
||||||
|
})
|
||||||
|
toast.ok('已更新')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(b: ChannelModelMapping) {
|
||||||
|
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
|
||||||
|
try {
|
||||||
|
await http.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
|
||||||
|
toast.ok('已解除')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 当前支持的模型 -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-1.5 text-xs font-medium text-muted">当前支持的模型({{ mappings.length }})</p>
|
||||||
|
<div v-if="mappings.length" class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="b in mappings"
|
||||||
|
:key="b.id"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-1 font-mono text-[11px] text-muted"
|
||||||
|
>
|
||||||
|
<span class="text-ink">{{ b.model_name }}</span>
|
||||||
|
<span class="opacity-60">→</span>
|
||||||
|
<input
|
||||||
|
v-model="b.upstream_model"
|
||||||
|
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-accent outline-none transition focus:border-accent/50 focus:bg-surface2"
|
||||||
|
@change="saveUpstream(b)"
|
||||||
|
/>
|
||||||
|
<button class="text-muted hover:text-err" aria-label="解除" @click="remove(b)">
|
||||||
|
<PhX :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-muted">尚未添加模型</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 从接口拉取 + 勾选 -->
|
||||||
|
<div class="border-t border-edge pt-3">
|
||||||
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
|
<p class="text-xs font-medium text-muted">从接口拉取模型</p>
|
||||||
|
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
|
||||||
|
<PhArrowsClockwise :size="13" />
|
||||||
|
拉取
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
|
||||||
|
<label
|
||||||
|
v-for="m in remote"
|
||||||
|
:key="m"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-edge2 px-2 py-1 font-mono text-[11px] text-muted transition select-none"
|
||||||
|
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'hover:border-edge'"
|
||||||
|
>
|
||||||
|
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
|
||||||
|
{{ m }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="remote.length" class="mt-2">
|
||||||
|
<Button size="sm" @click="addSelected">
|
||||||
|
<PhPlus :size="13" />
|
||||||
|
添加所选({{ selected.length }})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!loading" class="text-xs text-muted">点「拉取」获取渠道接口返回的模型,勾选需要的加入</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动添加 -->
|
||||||
|
<div class="flex items-center gap-2 border-t border-edge pt-3">
|
||||||
|
<input
|
||||||
|
v-model="addForm.custom_name"
|
||||||
|
placeholder="自定义名称(可选)"
|
||||||
|
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||||
|
@keyup.enter="addManual"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="addForm.upstream_model"
|
||||||
|
placeholder="上游模型名"
|
||||||
|
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||||
|
@keyup.enter="addManual"
|
||||||
|
/>
|
||||||
|
<Button size="sm" class="shrink-0" @click="addManual">
|
||||||
|
<PhPlus :size="13" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { PROTOCOL_OPTIONS, protocolName } from '@/lib/protocol'
|
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||||||
|
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import Modal from '@/components/ui/Modal.vue'
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
@@ -15,11 +17,17 @@ const editOpen = ref(false)
|
|||||||
const editing = ref<Channel | null>(null)
|
const editing = ref<Channel | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const busyId = ref<number | null>(null)
|
const busyId = ref<number | null>(null)
|
||||||
|
const expandedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function toggleDrawer(ch: Channel) {
|
||||||
|
expandedId.value = expandedId.value === ch.id ? null : ch.id
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
formats: ['chat'] as string[],
|
formats: ['chat'] as string[],
|
||||||
base_url: '',
|
base_url: '',
|
||||||
|
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
|
||||||
api_key: '',
|
api_key: '',
|
||||||
weight: 1,
|
weight: 1,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
@@ -40,7 +48,9 @@ async function load() {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: '', formats: ['chat'], base_url: '', api_key: '',
|
name: '', formats: ['chat'], base_url: '',
|
||||||
|
base_urls: { chat: '', responses: '', messages: '' },
|
||||||
|
api_key: '',
|
||||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||||
})
|
})
|
||||||
editOpen.value = true
|
editOpen.value = true
|
||||||
@@ -50,7 +60,13 @@ function openEdit(ch: Channel) {
|
|||||||
editing.value = ch
|
editing.value = ch
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
|
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
|
||||||
base_url: ch.base_url, api_key: '',
|
base_url: ch.base_url,
|
||||||
|
base_urls: {
|
||||||
|
chat: ch.base_urls?.chat ?? '',
|
||||||
|
responses: ch.base_urls?.responses ?? '',
|
||||||
|
messages: ch.base_urls?.messages ?? '',
|
||||||
|
},
|
||||||
|
api_key: '',
|
||||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||||
})
|
})
|
||||||
@@ -111,34 +127,72 @@ async function testChannel(ch: Channel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importModels(ch: Channel) {
|
|
||||||
busyId.value = ch.id
|
|
||||||
try {
|
|
||||||
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
|
|
||||||
toast.ok(`已导入 ${data.data.imported} 个模型`)
|
|
||||||
} catch (e) {
|
|
||||||
toast.err(errMsg(e))
|
|
||||||
} finally {
|
|
||||||
busyId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">渠道</h1>
|
<h1 class="text-lg font-semibold">渠道</h1>
|
||||||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="openCreate">添加渠道</Button>
|
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- 移动端:卡片列表 -->
|
||||||
|
<div class="space-y-3 md:hidden">
|
||||||
|
<div v-for="ch in channels" :key="ch.id" class="card p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
<code
|
||||||
|
v-for="f in ch.formats || []"
|
||||||
|
:key="f"
|
||||||
|
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||||||
|
>{{ protocolShort(f) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1.5">
|
||||||
|
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||||
|
{{ ch.health_status }}
|
||||||
|
</Badge>
|
||||||
|
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||||
|
<PhPulse :size="13" />
|
||||||
|
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
|
||||||
|
<PhStack :size="13" />
|
||||||
|
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||||
|
<PhNotePencil :size="13" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||||
|
<PhTrash :size="13" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
|
||||||
|
<ChannelModelsDrawer :channel="ch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||||||
|
还没有渠道,点击「添加渠道」
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端:表格 -->
|
||||||
|
<div class="card hidden md:block">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm min-w-[820px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||||
@@ -151,15 +205,21 @@ onMounted(load)
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
<template v-for="ch in channels" :key="ch.id">
|
||||||
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
|
<tr class="table-row">
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<div class="flex flex-wrap gap-1">
|
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||||||
<span
|
<span class="truncate">{{ ch.name }}</span>
|
||||||
|
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<code
|
||||||
v-for="f in ch.formats || []"
|
v-for="f in ch.formats || []"
|
||||||
:key="f"
|
:key="f"
|
||||||
class="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
|
class="font-mono text-[11px] leading-4 text-muted"
|
||||||
>{{ protocolName(f) }}</span>
|
>{{ protocolShort(f) }}</code>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
|
||||||
@@ -172,15 +232,27 @@ onMounted(load)
|
|||||||
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||||
|
<PhPulse :size="13" />
|
||||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
|
<PhNotePencil :size="13" />
|
||||||
<button class="text-xs text-muted hover:text-err" @click="remove(ch)">删除</button>
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||||
|
<PhTrash :size="13" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="expandedId === ch.id" class="bg-surface/40">
|
||||||
|
<td colspan="7" class="px-4 py-3">
|
||||||
|
<ChannelModelsDrawer :channel="ch" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
<tr v-if="channels.length === 0">
|
<tr v-if="channels.length === 0">
|
||||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
|
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -215,15 +287,22 @@ onMounted(load)
|
|||||||
<Input
|
<Input
|
||||||
v-model="form.base_url"
|
v-model="form.base_url"
|
||||||
label="Base URL(可选)"
|
label="Base URL(可选)"
|
||||||
placeholder="https://api.openai.com"
|
placeholder="https://api.openai.com/v1"
|
||||||
hint="留空按供应商默认;可填完整地址,如 https://api.openai.com/v1"
|
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
|
||||||
/>
|
/>
|
||||||
|
<div class="space-y-3 rounded-md border border-edge p-3">
|
||||||
|
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
|
||||||
|
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" />
|
||||||
|
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" />
|
||||||
|
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" />
|
||||||
|
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
v-model="form.api_key"
|
v-model="form.api_key"
|
||||||
label="上游 API Key"
|
label="上游 API Key"
|
||||||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||||||
/>
|
/>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input v-model="form.weight" label="权重" type="number" />
|
<Input v-model="form.weight" label="权重" type="number" />
|
||||||
<Input v-model="form.priority" label="优先级" type="number" />
|
<Input v-model="form.priority" label="优先级" type="number" />
|
||||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||||
|
|||||||
@@ -9,12 +9,31 @@ const config = reactive<Record<string, string>>({})
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const availableModels = ref<string[]>([])
|
||||||
|
const allowList = ref<string[]>([])
|
||||||
|
const denyList = ref<string[]>([])
|
||||||
|
|
||||||
|
function parseConfigList(v: unknown): string[] {
|
||||||
|
if (!v) return []
|
||||||
|
if (Array.isArray(v)) return v.map(String)
|
||||||
|
try {
|
||||||
|
const a = JSON.parse(String(v))
|
||||||
|
return Array.isArray(a) ? a.map(String) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/admin/config')
|
const { data } = await http.get('/admin/config')
|
||||||
Object.keys(config).forEach((k) => delete config[k])
|
Object.keys(config).forEach((k) => delete config[k])
|
||||||
Object.assign(config, data.data.config)
|
Object.assign(config, data.data.config)
|
||||||
|
allowList.value = parseConfigList(config.model_allowlist)
|
||||||
|
denyList.value = parseConfigList(config.model_denylist)
|
||||||
|
delete config.model_allowlist
|
||||||
|
delete config.model_denylist
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -22,10 +41,22 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/admin/models')
|
||||||
|
availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name)
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await http.put('/admin/config', config)
|
const payload: Record<string, unknown> = { ...config }
|
||||||
|
payload.model_allowlist = allowList.value
|
||||||
|
payload.model_denylist = denyList.value
|
||||||
|
await http.put('/admin/config', payload)
|
||||||
toast.ok('配置已保存')
|
toast.ok('配置已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
@@ -34,14 +65,59 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
loadModels()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
<div class="mb-6">
|
<div class="mb-2">
|
||||||
<h1 class="text-lg font-semibold">系统配置</h1>
|
<h1 class="text-lg font-semibold">系统配置</h1>
|
||||||
<p class="text-sm text-muted">注册策略等平台级配置</p>
|
<p class="text-sm text-muted">注册策略与模型访问限制</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-5">
|
||||||
|
<h2 class="text-sm font-semibold">模型限制</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
针对全部用户开放/禁止模型;用户级限制优先级更高(用户管理里可单独配置)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">允许的模型(留空 = 全部开放)</p>
|
||||||
|
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
|
||||||
|
<label
|
||||||
|
v-for="m in availableModels"
|
||||||
|
:key="m"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
|
||||||
|
:class="allowList.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
|
||||||
|
>
|
||||||
|
<input v-model="allowList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-accent)]" />
|
||||||
|
{{ m }}
|
||||||
|
</label>
|
||||||
|
<p v-if="availableModels.length === 0" class="text-xs text-muted">暂无模型,请先在模型定价中添加</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-xs font-medium text-muted">禁止的模型(黑名单优先)</p>
|
||||||
|
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
|
||||||
|
<label
|
||||||
|
v-for="m in availableModels"
|
||||||
|
:key="m"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
|
||||||
|
:class="denyList.includes(m) ? 'border-err bg-err-soft text-err' : 'border-edge2 text-muted hover:border-edge'"
|
||||||
|
>
|
||||||
|
<input v-model="denyList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-err)]" />
|
||||||
|
{{ m }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card space-y-5 p-6">
|
<div class="card space-y-5 p-6">
|
||||||
@@ -77,20 +153,12 @@ onMounted(load)
|
|||||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between rounded-md border border-edge bg-surface px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-ink">其他配置项</p>
|
|
||||||
<p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
|
||||||
</div>
|
|
||||||
<span class="font-mono text-xs text-muted">M3+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button :loading="saving" @click="save">保存</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button :loading="saving" @click="save">保存配置</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import Button from '@/components/ui/Button.vue'
|
|||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import Modal from '@/components/ui/Modal.vue'
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import type { Channel, Model } from '@/types'
|
import type { Channel, Model, ModelSummary } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const channels = ref<Channel[]>([])
|
const channels = ref<Channel[]>([])
|
||||||
|
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
|
||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const editing = ref<Model | null>(null)
|
const editing = ref<Model | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const quickName = ref('')
|
||||||
|
|
||||||
|
function quickAdd() {
|
||||||
|
openCreate()
|
||||||
|
if (quickName.value) form.name = quickName.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
const bindOpen = ref(false)
|
const bindOpen = ref(false)
|
||||||
const bindModel = ref<Model | null>(null)
|
const bindModel = ref<Model | null>(null)
|
||||||
@@ -33,6 +40,7 @@ async function load() {
|
|||||||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
||||||
models.value = m.data.data.items
|
models.value = m.data.data.items
|
||||||
channels.value = c.data.data.items
|
channels.value = c.data.data.items
|
||||||
|
summary.value = m.data.data.summary
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
}
|
}
|
||||||
@@ -127,22 +135,44 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">模型与定价</h1>
|
<h1 class="text-lg font-semibold">模型与定价</h1>
|
||||||
<p class="text-sm text-muted">价格按每百万 token (USD),历史用量按当时价格入账</p>
|
<p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full gap-2 sm:w-auto">
|
||||||
|
<input
|
||||||
|
v-model="quickName"
|
||||||
|
placeholder="模型名,如 glm-4.7-flash"
|
||||||
|
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-52 sm:flex-none"
|
||||||
|
@keyup.enter="quickAdd"
|
||||||
|
/>
|
||||||
|
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="openCreate">添加模型</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 一致性提示:渠道提供的未禁止模型应全部纳入定价 -->
|
||||||
|
<div v-if="summary.missing.length" class="card border-err/50 p-4">
|
||||||
|
<p class="text-sm font-medium text-err">以下渠道提供了未纳入定价的模型</p>
|
||||||
|
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
|
||||||
|
{{ x.channel }} → 模型 id#{{ x.model_id }}(请在下方添加并定价)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
|
||||||
|
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个在用模型未定价,网关将按示例价计费
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted">已启用渠道提供的全部模型均已纳入定价</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="m in models" :key="m.id" class="card">
|
<div v-for="m in models" :key="m.id" class="card">
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||||||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||||||
|
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
||||||
|
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||||||
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
||||||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||||||
@@ -181,7 +211,7 @@ onMounted(load)
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||||||
<Input v-model="form.display_name" label="展示名" />
|
<Input v-model="form.display_name" label="展示名" />
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||||
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { protocolName } from '@/lib/protocol'
|
import { protocolShort } from '@/lib/protocol'
|
||||||
import { fmtCost, fmtTime } from '@/lib/format'
|
import { fmtCost, fmtTime } from '@/lib/format'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
@@ -42,7 +42,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">用量统计</h1>
|
<h1 class="text-lg font-semibold">用量统计</h1>
|
||||||
<p class="text-sm text-muted">全局请求明细与成本</p>
|
<p class="text-sm text-muted">全局请求明细与成本</p>
|
||||||
@@ -51,16 +51,40 @@ onMounted(load)
|
|||||||
<input
|
<input
|
||||||
v-model="modelFilter"
|
v-model="modelFilter"
|
||||||
placeholder="按模型过滤"
|
placeholder="按模型过滤"
|
||||||
class="h-10 w-48 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-48 sm:flex-none"
|
||||||
@keyup.enter="search"
|
@keyup.enter="search"
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" @click="search">搜索</Button>
|
<Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="overflow-x-auto">
|
<!-- 移动端:卡片列表 -->
|
||||||
<table class="w-full text-sm">
|
<div class="space-y-3 p-3 md:hidden">
|
||||||
|
<div v-for="l in logs" :key="l.id" class="card p-3">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-muted">{{ l.user }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||||
|
<span class="mono-num text-sm font-medium text-ink">{{ fmtCost(l.cost) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
|
||||||
|
<span class="font-mono">{{ protocolShort(l.protocol) }}</span>
|
||||||
|
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
|
||||||
|
<span class="mono-num">{{ l.latency_ms }}ms</span>
|
||||||
|
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端:表格 -->
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="w-full text-sm min-w-[760px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
|
||||||
@@ -77,12 +101,12 @@ onMounted(load)
|
|||||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||||
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
|
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { PhNotePencil, PhCoins } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -20,7 +21,20 @@ const pageSize = 15
|
|||||||
|
|
||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const editing = ref<User | null>(null)
|
const editing = ref<User | null>(null)
|
||||||
const editForm = reactive({ role: 'user', status: 'active' })
|
const editForm = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
allowed_models: '',
|
||||||
|
denied_models: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseModelList(s: string): string[] | undefined {
|
||||||
|
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
|
||||||
|
return arr.length ? arr : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const balanceOpen = ref(false)
|
const balanceOpen = ref(false)
|
||||||
const balanceUser = ref<User | null>(null)
|
const balanceUser = ref<User | null>(null)
|
||||||
@@ -43,14 +57,33 @@ function search() {
|
|||||||
|
|
||||||
function openEdit(u: User) {
|
function openEdit(u: User) {
|
||||||
editing.value = u
|
editing.value = u
|
||||||
Object.assign(editForm, { role: u.role, status: u.status })
|
Object.assign(editForm, {
|
||||||
|
username: u.username,
|
||||||
|
email: u.email,
|
||||||
|
password: '',
|
||||||
|
role: u.role,
|
||||||
|
status: u.status,
|
||||||
|
allowed_models: (u.allowed_models || []).join(', '),
|
||||||
|
denied_models: (u.denied_models || []).join(', '),
|
||||||
|
})
|
||||||
editOpen.value = true
|
editOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!editing.value) return
|
if (!editing.value) return
|
||||||
try {
|
try {
|
||||||
await http.patch(`/admin/users/${editing.value.id}`, editForm)
|
const payload: Record<string, unknown> = {
|
||||||
|
username: editForm.username,
|
||||||
|
email: editForm.email,
|
||||||
|
role: editForm.role,
|
||||||
|
status: editForm.status,
|
||||||
|
}
|
||||||
|
if (editForm.password) payload.password = editForm.password
|
||||||
|
const allow = parseModelList(editForm.allowed_models)
|
||||||
|
const deny = parseModelList(editForm.denied_models)
|
||||||
|
payload.allowed_models = allow ?? []
|
||||||
|
payload.denied_models = deny ?? []
|
||||||
|
await http.patch(`/admin/users/${editing.value.id}`, payload)
|
||||||
toast.ok('已更新')
|
toast.ok('已更新')
|
||||||
editOpen.value = false
|
editOpen.value = false
|
||||||
await load()
|
await load()
|
||||||
@@ -90,25 +123,25 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold">用户</h1>
|
<h1 class="text-lg font-semibold">用户</h1>
|
||||||
<p class="text-sm text-muted">管理角色、状态与余额</p>
|
<p class="text-sm text-muted">管理角色、状态与余额</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex w-full gap-2 sm:w-auto">
|
||||||
<input
|
<input
|
||||||
v-model="q"
|
v-model="q"
|
||||||
placeholder="搜索用户名 / 邮箱"
|
placeholder="搜索用户名 / 邮箱"
|
||||||
class="h-10 w-56 rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent"
|
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent sm:w-56 sm:flex-none"
|
||||||
@keyup.enter="search"
|
@keyup.enter="search"
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" @click="search">搜索</Button>
|
<Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm min-w-[720px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
|
||||||
@@ -139,8 +172,17 @@ onMounted(load)
|
|||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(u)">编辑</button>
|
<button
|
||||||
<button class="text-xs text-muted hover:text-accent" @click="openBalance(u)">调余额</button>
|
class="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent-soft px-2 py-1 text-xs font-medium text-accent transition hover:border-accent hover:bg-accent/15"
|
||||||
|
@click="openEdit(u)"
|
||||||
|
>
|
||||||
|
<PhNotePencil :size="13" weight="bold" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center gap-1 text-xs text-muted transition hover:text-accent" @click="openBalance(u)">
|
||||||
|
<PhCoins :size="13" />
|
||||||
|
调余额
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -160,22 +202,45 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑用户 -->
|
<!-- 编辑用户 -->
|
||||||
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
|
<Modal :open="editOpen" :title="`编辑用户 · ${editing?.username}`" @close="editOpen = false">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block">
|
<Input v-model="editForm.username" label="用户名" />
|
||||||
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
|
<Input v-model="editForm.email" label="邮箱" type="email" />
|
||||||
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
|
<Input
|
||||||
<option value="user">user</option>
|
v-model="editForm.password"
|
||||||
<option value="admin">admin</option>
|
label="重置密码"
|
||||||
</select>
|
type="password"
|
||||||
</label>
|
autocomplete="new-password"
|
||||||
<label class="block">
|
hint="留空则不修改"
|
||||||
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
|
/>
|
||||||
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
|
<Input
|
||||||
<option value="active">active</option>
|
v-model="editForm.allowed_models"
|
||||||
<option value="disabled">disabled</option>
|
label="允许的模型"
|
||||||
</select>
|
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
|
||||||
</label>
|
hint="留空不限制;用户级白名单优先于全局"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="editForm.denied_models"
|
||||||
|
label="禁止的模型"
|
||||||
|
placeholder="逗号分隔"
|
||||||
|
hint="黑名单优先"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
|
||||||
|
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
|
||||||
|
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="disabled">disabled</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
import { PhCopy, PhCheck, PhNotePencil } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { fmtTime } from '@/lib/format'
|
import { fmtNum, fmtTime } from '@/lib/format'
|
||||||
|
import { copyText } from '@/lib/clipboard'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import Modal from '@/components/ui/Modal.vue'
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
@@ -15,7 +16,6 @@ const keys = ref<ApiKey[]>([])
|
|||||||
|
|
||||||
const createOpen = ref(false)
|
const createOpen = ref(false)
|
||||||
const keyName = ref('')
|
const keyName = ref('')
|
||||||
const advOpen = ref(false)
|
|
||||||
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
|
||||||
@@ -45,6 +45,13 @@ function parseModels(s: string): string[] | undefined {
|
|||||||
return arr.length ? arr : undefined
|
return arr.length ? arr : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quotaText(k: ApiKey): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (k.quota_tokens_per_day) parts.push(`${fmtNum(k.quota_tokens_per_day)} tok/日`)
|
||||||
|
if (k.quota_requests_per_day) parts.push(`${fmtNum(k.quota_requests_per_day)} req/日`)
|
||||||
|
return parts.length ? parts.join(' · ') : '不限'
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get('/keys')
|
const { data } = await http.get('/keys')
|
||||||
@@ -70,7 +77,6 @@ async function createKey() {
|
|||||||
createOpen.value = false
|
createOpen.value = false
|
||||||
keyName.value = ''
|
keyName.value = ''
|
||||||
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||||
advOpen.value = false
|
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
@@ -113,11 +119,11 @@ async function saveEdit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke(k: ApiKey) {
|
async function remove(k: ApiKey) {
|
||||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
if (!confirm(`删除密钥 ${k.name}?删除后立即失效且不可恢复。`)) return
|
||||||
try {
|
try {
|
||||||
await http.delete(`/keys/${k.id}`)
|
await http.delete(`/keys/${k.id}`)
|
||||||
toast.ok('密钥已吊销')
|
toast.ok('密钥已删除')
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
@@ -126,11 +132,11 @@ async function revoke(k: ApiKey) {
|
|||||||
|
|
||||||
async function copyKey() {
|
async function copyKey() {
|
||||||
if (!created.value) return
|
if (!created.value) return
|
||||||
try {
|
const ok = await copyText(created.value.key)
|
||||||
await navigator.clipboard.writeText(created.value.key)
|
if (ok) {
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => (copied.value = false), 1500)
|
setTimeout(() => (copied.value = false), 1500)
|
||||||
} catch {
|
} else {
|
||||||
toast.err('复制失败,请手动复制')
|
toast.err('复制失败,请手动复制')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,15 +155,56 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="overflow-x-auto">
|
<!-- 移动端:卡片列表 -->
|
||||||
<table class="w-full text-sm">
|
<div class="space-y-3 p-3 md:hidden">
|
||||||
|
<div v-for="k in keys" :key="k.id" class="card p-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-ink">{{ k.name }}</p>
|
||||||
|
<p class="truncate font-mono text-xs text-muted">{{ k.key_prefix }}…</p>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 space-y-1.5 text-xs">
|
||||||
|
<p class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="shrink-0 text-muted">配额</span>
|
||||||
|
<span class="mono-num truncate text-ink">{{ quotaText(k) }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="shrink-0 text-muted">过期</span>
|
||||||
|
<span class="mono-num truncate text-ink">{{ fmtTime(k.expires_at) }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex items-baseline justify-between gap-3">
|
||||||
|
<span class="shrink-0 text-muted">最近使用</span>
|
||||||
|
<span class="mono-num truncate text-ink">{{ fmtTime(k.last_used_at) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 flex gap-2 border-t border-edge pt-2.5">
|
||||||
|
<Button size="sm" variant="ghost" class="flex-1" @click="openEdit(k)">
|
||||||
|
<PhNotePencil :size="13" weight="bold" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button v-if="k.status === 'active'" size="sm" variant="danger" class="flex-1" @click="remove(k)">
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="keys.length === 0" class="card px-4 py-8 text-center text-sm text-muted">
|
||||||
|
还没有密钥,点击右上角「新建密钥」
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端:表格 -->
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="w-full text-sm min-w-[680px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
|
||||||
|
<th scope="col" class="px-4 py-2.5 font-medium">配额</th>
|
||||||
|
<th scope="col" class="px-4 py-2.5 font-medium">过期</th>
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
|
|
||||||
<th scope="col" class="px-4 py-2.5" />
|
<th scope="col" class="px-4 py-2.5" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -165,26 +212,40 @@ onMounted(load)
|
|||||||
<tr v-for="k in keys" :key="k.id" class="table-row">
|
<tr v-for="k in keys" :key="k.id" class="table-row">
|
||||||
<td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
|
<td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">
|
||||||
|
<template v-if="k.quota_tokens_per_day || k.quota_requests_per_day">
|
||||||
|
<span v-if="k.quota_tokens_per_day">{{ fmtNum(k.quota_tokens_per_day) }} tok/日</span>
|
||||||
|
<span v-if="k.quota_tokens_per_day && k.quota_requests_per_day"> · </span>
|
||||||
|
<span v-if="k.quota_requests_per_day">{{ fmtNum(k.quota_requests_per_day) }} req/日</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-muted">不限</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.expires_at) }}</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<div class="flex justify-end gap-2.5">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
|
<button
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent-soft px-2 py-1 text-xs font-medium text-accent transition hover:border-accent"
|
||||||
|
@click="openEdit(k)"
|
||||||
|
>
|
||||||
|
<PhNotePencil :size="13" weight="bold" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="k.status === 'active'"
|
v-if="k.status === 'active'"
|
||||||
class="text-xs text-muted hover:text-err"
|
class="text-xs text-muted transition hover:text-err"
|
||||||
@click="revoke(k)"
|
@click="remove(k)"
|
||||||
>
|
>
|
||||||
吊销
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="keys.length === 0">
|
<tr v-if="keys.length === 0">
|
||||||
<td colspan="6" class="px-4 py-10 text-center text-sm text-muted">
|
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">
|
||||||
还没有密钥,点击右上角「新建密钥」
|
还没有密钥,点击右上角「新建密钥」
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -197,18 +258,16 @@ onMounted(load)
|
|||||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
|
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
|
||||||
<div>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<button class="text-xs text-muted transition hover:text-ink" @click="advOpen = !advOpen">
|
<Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限(可选)" placeholder="如 100000" />
|
||||||
{{ advOpen ? '收起' : '展开' }}高级选项(配额 / 白名单)
|
<Input v-model="createAdv.quota_requests_per_day" label="每日请求上限(可选)" placeholder="如 1000" />
|
||||||
</button>
|
|
||||||
<div v-if="advOpen" class="mt-3 space-y-4">
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
|
||||||
<Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限" placeholder="如 100000" />
|
|
||||||
<Input v-model="createAdv.quota_requests_per_day" label="每日请求上限" placeholder="如 1000" />
|
|
||||||
</div>
|
|
||||||
<Input v-model="createAdv.allowed_models" label="模型白名单" placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="createAdv.allowed_models"
|
||||||
|
label="模型白名单(可选)"
|
||||||
|
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
|
||||||
|
hint="留空则不限模型"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
||||||
@@ -220,7 +279,7 @@ onMounted(load)
|
|||||||
<Modal :open="editOpen" :title="`编辑密钥 · ${editing?.name}`" @close="editOpen = false">
|
<Modal :open="editOpen" :title="`编辑密钥 · ${editing?.name}`" @close="editOpen = false">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Input v-model="editForm.name" label="名称" />
|
<Input v-model="editForm.name" label="名称" />
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<Input v-model="editForm.quota_tokens_per_day" label="每日 Token 上限" placeholder="留空则不修改" />
|
<Input v-model="editForm.quota_tokens_per_day" label="每日 Token 上限" placeholder="留空则不修改" />
|
||||||
<Input v-model="editForm.quota_requests_per_day" label="每日请求上限" placeholder="留空则不修改" />
|
<Input v-model="editForm.quota_requests_per_day" label="每日请求上限" placeholder="留空则不修改" />
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +291,7 @@ onMounted(load)
|
|||||||
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
|
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
|
||||||
>
|
>
|
||||||
<option value="active">active(启用)</option>
|
<option value="active">active(启用)</option>
|
||||||
<option value="revoked">revoked(吊销)</option>
|
<option value="revoked">revoked(停用)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,11 +305,11 @@ onMounted(load)
|
|||||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
|
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent">
|
<code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent">
|
||||||
{{ created?.key }}
|
{{ created?.key }}
|
||||||
</code>
|
</code>
|
||||||
<Button size="sm" @click="copyKey">
|
<Button size="sm" class="shrink-0" @click="copyKey">
|
||||||
<PhCheck v-if="copied" :size="14" />
|
<PhCheck v-if="copied" :size="14" />
|
||||||
<PhCopy v-else :size="14" />
|
<PhCopy v-else :size="14" />
|
||||||
{{ copied ? '已复制' : '复制' }}
|
{{ copied ? '已复制' : '复制' }}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { PhFingerprint } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { fmtMoney, fmtTime } from '@/lib/format'
|
import { fmtMoney, fmtTime } from '@/lib/format'
|
||||||
|
import { registerPasskey, isWebAuthnSupported } from '@/lib/webauthn'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
@@ -11,6 +13,71 @@ import Input from '@/components/ui/Input.vue'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const passkeys = ref<{ id: number; name: string; created_at: string }[]>([])
|
||||||
|
const binding = ref(false)
|
||||||
|
const passkeyName = ref('')
|
||||||
|
|
||||||
|
// 按 UA 推断设备名,方便多设备区分
|
||||||
|
function detectPlatform(): string {
|
||||||
|
const ua = navigator.userAgent || ''
|
||||||
|
if (/iPhone|iPad|iPod/.test(ua)) return 'iPhone'
|
||||||
|
if (/Android/.test(ua)) return 'Android'
|
||||||
|
if (/Windows/.test(ua)) return 'Windows'
|
||||||
|
if (/Macintosh|Mac OS/.test(ua)) return 'Mac'
|
||||||
|
if (/Linux/.test(ua)) return 'Linux'
|
||||||
|
return 'Passkey'
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePasskeyName(): string {
|
||||||
|
return passkeyName.value.trim() || detectPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPasskeys() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/webauthn/passkeys')
|
||||||
|
passkeys.value = data.data.items
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindPasskey() {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/webauthn/register/begin')
|
||||||
|
const credential = await registerPasskey(data.data.creation)
|
||||||
|
await http.post('/webauthn/register/complete', {
|
||||||
|
challenge: data.data.challenge,
|
||||||
|
name: usePasskeyName(),
|
||||||
|
credential,
|
||||||
|
})
|
||||||
|
toast.ok('Passkey 已绑定')
|
||||||
|
passkeyName.value = ''
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
binding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePasskey(id: number) {
|
||||||
|
if (!confirm('解除该 Passkey?解除后需重新绑定才能免密登录。')) return
|
||||||
|
try {
|
||||||
|
await http.delete(`/webauthn/passkeys/${id}`)
|
||||||
|
toast.ok('已解除')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPasskeys)
|
||||||
|
|
||||||
const oldPwd = ref('')
|
const oldPwd = ref('')
|
||||||
const newPwd = ref('')
|
const newPwd = ref('')
|
||||||
const confirmPwd = ref('')
|
const confirmPwd = ref('')
|
||||||
@@ -47,7 +114,7 @@ async function changePassword() {
|
|||||||
|
|
||||||
<div class="card p-5">
|
<div class="card p-5">
|
||||||
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
|
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
|
||||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs text-muted">用户名</dt>
|
<dt class="text-xs text-muted">用户名</dt>
|
||||||
<dd class="mt-1 text-ink">{{ auth.user?.username }}</dd>
|
<dd class="mt-1 text-ink">{{ auth.user?.username }}</dd>
|
||||||
@@ -80,5 +147,34 @@ async function changePassword() {
|
|||||||
<Button :loading="saving" @click="changePassword">更新密码</Button>
|
<Button :loading="saving" @click="changePassword">更新密码</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-5">
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 class="text-sm font-semibold">Passkey 登录</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="passkeyName"
|
||||||
|
placeholder="设备名,如 iPhone"
|
||||||
|
class="h-8 w-32 rounded-md border border-edge2 bg-surface px-2 text-xs outline-none focus:border-accent"
|
||||||
|
@keyup.enter="bindPasskey"
|
||||||
|
/>
|
||||||
|
<Button size="sm" :loading="binding" @click="bindPasskey">
|
||||||
|
<PhFingerprint :size="14" />
|
||||||
|
绑定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3 text-xs text-muted">用生物识别或系统 PIN 免密登录。需要 HTTPS 或 localhost 环境。</p>
|
||||||
|
<ul v-if="passkeys.length" class="divide-y divide-edge">
|
||||||
|
<li v-for="pk in passkeys" :key="pk.id" class="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-ink">{{ pk.name }}</p>
|
||||||
|
<p class="font-mono text-xs text-muted">{{ fmtTime(pk.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="text-xs text-muted transition hover:text-err" @click="removePasskey(pk.id)">解除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-xs text-muted">尚未绑定 Passkey</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||||
import { protocolName } from '@/lib/protocol'
|
import { protocolShort } from '@/lib/protocol'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||||
import type { UsageLog } from '@/types'
|
import type { UsageLog } from '@/types'
|
||||||
@@ -98,17 +98,41 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between border-b border-edge px-4 py-3">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-edge px-4 py-3">
|
||||||
<h2 class="text-sm font-semibold">请求明细</h2>
|
<h2 class="text-sm font-semibold">请求明细</h2>
|
||||||
<input
|
<input
|
||||||
v-model="modelFilter"
|
v-model="modelFilter"
|
||||||
placeholder="按模型过滤"
|
placeholder="按模型过滤"
|
||||||
class="h-8 w-48 rounded-md border border-edge2 bg-surface px-2.5 font-mono text-xs outline-none focus:border-accent"
|
class="h-8 w-full rounded-md border border-edge2 bg-surface px-2.5 font-mono text-xs outline-none focus:border-accent sm:w-48"
|
||||||
@keyup.enter="page = 1; load()"
|
@keyup.enter="page = 1; load()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
<!-- 移动端:卡片列表 -->
|
||||||
|
<div class="space-y-3 p-3 md:hidden">
|
||||||
|
<div v-for="l in logs" :key="l.id" class="card p-3">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
|
||||||
|
<p class="mt-0.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||||
|
<span class="mono-num text-sm font-medium text-ink">{{ fmtCost(l.cost) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
|
||||||
|
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
|
||||||
|
<span class="mono-num">{{ l.latency_ms }}ms</span>
|
||||||
|
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端:表格 -->
|
||||||
|
<div class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="w-full text-sm min-w-[640px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
|
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
|
||||||
@@ -123,11 +147,11 @@ onMounted(load)
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
||||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
|
||||||
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
|
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge></td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="logs.length === 0">
|
<tr v-if="logs.length === 0">
|
||||||
|
|||||||
Reference in New Issue
Block a user