mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bae525026 | ||
|
|
df00805a2a | ||
|
|
a88ee96518 | ||
|
|
3cc2f9bd57 | ||
|
|
d1b684b782 | ||
|
|
6460d4ad3a | ||
|
|
19ea392d5d | ||
|
|
fb4d016176 | ||
|
|
afec747d9e | ||
|
|
7388fcce41 | ||
|
|
a6f9f9f968 | ||
|
|
29759721e0 | ||
|
|
1941b20521 | ||
|
|
e6969acb50 | ||
|
|
9489531431 | ||
|
|
32b7c0ca9b | ||
|
|
4ac57b4edf | ||
|
|
685a1e0ba3 | ||
|
|
e350aab1bd | ||
|
|
0dd6986e28 | ||
|
|
6d0102a70c | ||
|
|
f96a2a18c1 | ||
|
|
f955b04a6f | ||
|
|
2fd6ac319b | ||
|
|
82fbf452a8 | ||
|
|
ba69736f55 | ||
|
|
c75c6b6858 | ||
|
|
de61745bb2 | ||
|
|
3fab0fcd4c | ||
|
|
03bcd94ae5 | ||
|
|
0343bc7777 | ||
|
|
565d19acfd | ||
|
|
960acf1982 | ||
|
|
ece911521e | ||
|
|
5d95e59742 | ||
|
|
01d084bbfd | ||
|
|
7918fc2844 | ||
|
|
31b30a6df2 | ||
|
|
d217b59e0b | ||
|
|
169a4b9d32 | ||
|
|
15f3ffb165 | ||
|
|
02db1010dd | ||
|
|
935ea66681 | ||
|
|
26060e702f | ||
|
|
65d4ca2563 | ||
|
|
3c619a8da5 |
@@ -113,7 +113,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
||||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
|
driveClient := repository.NewGeminiDriveClient()
|
||||||
|
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, driveClient, configConfig)
|
||||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||||
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
|
||||||
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
|
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
|
||||||
@@ -187,9 +188,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
||||||
soraDirectClient := service.ProvideSoraDirectClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
|
soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
|
||||||
soraMediaStorage := service.ProvideSoraMediaStorage(configConfig)
|
soraMediaStorage := service.ProvideSoraMediaStorage(configConfig)
|
||||||
soraGatewayService := service.NewSoraGatewayService(soraDirectClient, soraMediaStorage, rateLimitService, configConfig)
|
soraGatewayService := service.NewSoraGatewayService(soraSDKClient, soraMediaStorage, rateLimitService, configConfig)
|
||||||
soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig)
|
soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig)
|
||||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||||
totpHandler := handler.NewTotpHandler(totpService)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.25.7
|
|||||||
require (
|
require (
|
||||||
entgo.io/ent v0.14.5
|
entgo.io/ent v0.14.5
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
|
github.com/DouDOU-start/go-sora2api v1.1.0
|
||||||
github.com/alitto/pond/v2 v2.6.2
|
github.com/alitto/pond/v2 v2.6.2
|
||||||
github.com/cespare/xxhash/v2 v2.3.0
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/dgraph-io/ristretto v0.2.0
|
github.com/dgraph-io/ristretto v0.2.0
|
||||||
@@ -29,10 +30,10 @@ require (
|
|||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/zeromicro/go-zero v1.9.4
|
github.com/zeromicro/go-zero v1.9.4
|
||||||
go.uber.org/zap v1.24.0
|
go.uber.org/zap v1.24.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.49.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/term v0.39.0
|
golang.org/x/term v0.40.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.44.3
|
modernc.org/sqlite v1.44.3
|
||||||
@@ -46,7 +47,14 @@ require (
|
|||||||
github.com/agext/levenshtein v1.2.3 // indirect
|
github.com/agext/levenshtein v1.2.3 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||||
|
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||||
|
github.com/bdandy/go-socks4 v1.2.3 // indirect
|
||||||
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
||||||
|
github.com/bogdanfinn/fhttp v0.6.8 // indirect
|
||||||
|
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||||
|
github.com/bogdanfinn/tls-client v1.14.0 // indirect
|
||||||
|
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
|
||||||
|
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
@@ -123,6 +131,7 @@ require (
|
|||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
@@ -144,9 +153,9 @@ require (
|
|||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/grpc v1.75.1 // indirect
|
google.golang.org/grpc v1.75.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/DouDOU-start/go-sora2api v1.1.0 h1:PxWiukK77StiHxEngOFwT1rKUn9oTAJJTl07wQUXwiU=
|
||||||
|
github.com/DouDOU-start/go-sora2api v1.1.0/go.mod h1:dcwpethoKfAsMWskDD9iGgc/3yox2tkthPLSMVGnhkE=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
||||||
@@ -20,10 +22,24 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||||
|
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||||
|
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||||
|
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||||
|
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
|
||||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
|
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
|
||||||
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
||||||
|
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
|
||||||
|
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
|
||||||
|
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
|
||||||
|
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
|
||||||
|
github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A=
|
||||||
|
github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
|
||||||
|
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
|
||||||
|
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
|
||||||
|
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
|
||||||
|
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
@@ -279,6 +295,8 @@ 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/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
|
||||||
|
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||||
@@ -345,18 +363,21 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
|||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -364,16 +385,19 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||||
|
|||||||
@@ -1088,9 +1088,9 @@ func setDefaults() {
|
|||||||
// RateLimit
|
// RateLimit
|
||||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||||
|
|
||||||
// Pricing - 从 price-mirror 分支同步,该分支维护了 sha256 哈希文件用于增量更新检查
|
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据的配置
|
||||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/price-mirror/model_prices_and_context_window.json")
|
viper.SetDefault("pricing.remote_url", "https://github.com/Wei-Shaw/model-price-repo/raw/refs/heads/main/model_prices_and_context_window.json")
|
||||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/claude-relay-service/price-mirror/model_prices_and_context_window.sha256")
|
viper.SetDefault("pricing.hash_url", "https://github.com/Wei-Shaw/model-price-repo/raw/refs/heads/main/model_prices_and_context_window.sha256")
|
||||||
viper.SetDefault("pricing.data_dir", "./data")
|
viper.SetDefault("pricing.data_dir", "./data")
|
||||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||||
|
|||||||
@@ -89,19 +89,21 @@ var DefaultAntigravityModelMapping = map[string]string{
|
|||||||
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
|
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
|
||||||
"gemini-2.5-pro": "gemini-2.5-pro",
|
"gemini-2.5-pro": "gemini-2.5-pro",
|
||||||
// Gemini 3 白名单
|
// Gemini 3 白名单
|
||||||
"gemini-3-flash": "gemini-3-flash",
|
"gemini-3-flash": "gemini-3-flash",
|
||||||
"gemini-3-pro-high": "gemini-3-pro-high",
|
"gemini-3-pro-high": "gemini-3-pro-high",
|
||||||
"gemini-3-pro-low": "gemini-3-pro-low",
|
"gemini-3-pro-low": "gemini-3-pro-low",
|
||||||
"gemini-3-pro-image": "gemini-3-pro-image",
|
|
||||||
// Gemini 3 preview 映射
|
// Gemini 3 preview 映射
|
||||||
"gemini-3-flash-preview": "gemini-3-flash",
|
"gemini-3-flash-preview": "gemini-3-flash",
|
||||||
"gemini-3-pro-preview": "gemini-3-pro-high",
|
"gemini-3-pro-preview": "gemini-3-pro-high",
|
||||||
"gemini-3-pro-image-preview": "gemini-3-pro-image",
|
|
||||||
// Gemini 3.1 白名单
|
// Gemini 3.1 白名单
|
||||||
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
|
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
|
||||||
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
||||||
// Gemini 3.1 preview 映射
|
// Gemini 3.1 preview 映射
|
||||||
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
||||||
|
// Gemini 3.1 image 白名单
|
||||||
|
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||||
|
// Gemini 3.1 image preview 映射
|
||||||
|
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
|
||||||
// 其他官方模型
|
// 其他官方模型
|
||||||
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
|
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
|
||||||
"tab_flash_lite_preview": "tab_flash_lite_preview",
|
"tab_flash_lite_preview": "tab_flash_lite_preview",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
@@ -1459,32 +1460,8 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
|||||||
|
|
||||||
// Handle Antigravity accounts: return Claude + Gemini models
|
// Handle Antigravity accounts: return Claude + Gemini models
|
||||||
if account.Platform == service.PlatformAntigravity {
|
if account.Platform == service.PlatformAntigravity {
|
||||||
// Antigravity 支持 Claude 和部分 Gemini 模型
|
// 直接复用 antigravity.DefaultModels(),与 /v1/models 端点保持同步
|
||||||
type UnifiedModel struct {
|
response.Success(c, antigravity.DefaultModels())
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var models []UnifiedModel
|
|
||||||
|
|
||||||
// 添加 Claude 模型
|
|
||||||
for _, m := range claude.DefaultModels {
|
|
||||||
models = append(models, UnifiedModel{
|
|
||||||
ID: m.ID,
|
|
||||||
Type: m.Type,
|
|
||||||
DisplayName: m.DisplayName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 Gemini 3 系列模型用于测试
|
|
||||||
geminiTestModels := []UnifiedModel{
|
|
||||||
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash"},
|
|
||||||
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview"},
|
|
||||||
}
|
|
||||||
models = append(models, geminiTestModels...)
|
|
||||||
|
|
||||||
response.Success(c, models)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ var claudeModels = []modelDef{
|
|||||||
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
|
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
|
||||||
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
|
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||||
{ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"},
|
{ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||||
|
{ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6", CreatedAt: "2026-02-05T00:00:00Z"},
|
||||||
|
{ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", CreatedAt: "2026-02-17T00:00:00Z"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antigravity 支持的 Gemini 模型
|
// Antigravity 支持的 Gemini 模型
|
||||||
@@ -161,6 +163,8 @@ var geminiModels = []modelDef{
|
|||||||
{ID: "gemini-3-flash", DisplayName: "Gemini 3 Flash", CreatedAt: "2025-06-01T00:00:00Z"},
|
{ID: "gemini-3-flash", DisplayName: "Gemini 3 Flash", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||||
{ID: "gemini-3-pro-low", DisplayName: "Gemini 3 Pro Low", CreatedAt: "2025-06-01T00:00:00Z"},
|
{ID: "gemini-3-pro-low", DisplayName: "Gemini 3 Pro Low", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||||
{ID: "gemini-3-pro-high", DisplayName: "Gemini 3 Pro High", CreatedAt: "2025-06-01T00:00:00Z"},
|
{ID: "gemini-3-pro-high", DisplayName: "Gemini 3 Pro High", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||||
|
{ID: "gemini-3.1-pro-low", DisplayName: "Gemini 3.1 Pro Low", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||||
|
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||||
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z"},
|
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||||
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ const (
|
|||||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.18.4
|
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.19.6
|
||||||
var defaultUserAgentVersion = "1.18.4"
|
var defaultUserAgentVersion = "1.19.6"
|
||||||
|
|
||||||
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
|
||||||
var defaultClientSecret = "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"
|
var defaultClientSecret = "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"
|
||||||
|
|||||||
@@ -690,7 +690,7 @@ func TestConstants_值正确(t *testing.T) {
|
|||||||
if RedirectURI != "http://localhost:8085/callback" {
|
if RedirectURI != "http://localhost:8085/callback" {
|
||||||
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
|
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
|
||||||
}
|
}
|
||||||
if GetUserAgent() != "antigravity/1.18.4 windows/amd64" {
|
if GetUserAgent() != "antigravity/1.19.6 windows/amd64" {
|
||||||
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
|
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
|
||||||
}
|
}
|
||||||
if SessionTTL != 30*time.Minute {
|
if SessionTTL != 30*time.Minute {
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ const (
|
|||||||
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14"
|
||||||
BetaTokenCounting = "token-counting-2024-11-01"
|
BetaTokenCounting = "token-counting-2024-11-01"
|
||||||
BetaContext1M = "context-1m-2025-08-07"
|
BetaContext1M = "context-1m-2025-08-07"
|
||||||
|
BetaFastMode = "fast-mode-2026-02-01"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||||
|
// 这些 token 是客户端特有的,不应透传给上游 API。
|
||||||
|
var DroppedBetas = []string{BetaContext1M, BetaFastMode}
|
||||||
|
|
||||||
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
|
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
|
||||||
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
||||||
|
|
||||||
|
|||||||
9
backend/internal/repository/gemini_drive_client.go
Normal file
9
backend/internal/repository/gemini_drive_client.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||||
|
|
||||||
|
// NewGeminiDriveClient creates a concrete DriveClient for Google Drive API operations.
|
||||||
|
// Returned as geminicli.DriveClient interface for DI (Strategy A).
|
||||||
|
func NewGeminiDriveClient() geminicli.DriveClient {
|
||||||
|
return geminicli.NewDriveClient()
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewOpenAIOAuthClient,
|
NewOpenAIOAuthClient,
|
||||||
NewGeminiOAuthClient,
|
NewGeminiOAuthClient,
|
||||||
NewGeminiCliCodeAssistClient,
|
NewGeminiCliCodeAssistClient,
|
||||||
|
NewGeminiDriveClient,
|
||||||
|
|
||||||
ProvideEnt,
|
ProvideEnt,
|
||||||
ProvideSQLDB,
|
ProvideSQLDB,
|
||||||
|
|||||||
@@ -3757,14 +3757,17 @@ func (s *AntigravityGatewayService) extractImageSize(body []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isImageGenerationModel 判断模型是否为图片生成模型
|
// isImageGenerationModel 判断模型是否为图片生成模型
|
||||||
// 支持的模型:gemini-3-pro-image, gemini-3-pro-image-preview, gemini-2.5-flash-image 等
|
// 支持的模型:gemini-3.1-flash-image, gemini-3-pro-image, gemini-2.5-flash-image 等
|
||||||
func isImageGenerationModel(model string) bool {
|
func isImageGenerationModel(model string) bool {
|
||||||
modelLower := strings.ToLower(model)
|
modelLower := strings.ToLower(model)
|
||||||
// 移除 models/ 前缀
|
// 移除 models/ 前缀
|
||||||
modelLower = strings.TrimPrefix(modelLower, "models/")
|
modelLower = strings.TrimPrefix(modelLower, "models/")
|
||||||
|
|
||||||
// 精确匹配或前缀匹配
|
// 精确匹配或前缀匹配
|
||||||
return modelLower == "gemini-3-pro-image" ||
|
return modelLower == "gemini-3.1-flash-image" ||
|
||||||
|
modelLower == "gemini-3.1-flash-image-preview" ||
|
||||||
|
strings.HasPrefix(modelLower, "gemini-3.1-flash-image-") ||
|
||||||
|
modelLower == "gemini-3-pro-image" ||
|
||||||
modelLower == "gemini-3-pro-image-preview" ||
|
modelLower == "gemini-3-pro-image-preview" ||
|
||||||
strings.HasPrefix(modelLower, "gemini-3-pro-image-") ||
|
strings.HasPrefix(modelLower, "gemini-3-pro-image-") ||
|
||||||
modelLower == "gemini-2.5-flash-image" ||
|
modelLower == "gemini-2.5-flash-image" ||
|
||||||
|
|||||||
@@ -543,7 +543,10 @@ func (s *BillingService) getDefaultImagePrice(model string, imageSize string) fl
|
|||||||
basePrice = 0.134
|
basePrice = 0.134
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4K 尺寸翻倍
|
// 2K 尺寸 1.5 倍,4K 尺寸翻倍
|
||||||
|
if imageSize == "2K" {
|
||||||
|
return basePrice * 1.5
|
||||||
|
}
|
||||||
if imageSize == "4K" {
|
if imageSize == "4K" {
|
||||||
return basePrice * 2
|
return basePrice * 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import (
|
|||||||
func TestCalculateImageCost_DefaultPricing(t *testing.T) {
|
func TestCalculateImageCost_DefaultPricing(t *testing.T) {
|
||||||
svc := &BillingService{} // pricingService 为 nil,使用硬编码默认值
|
svc := &BillingService{} // pricingService 为 nil,使用硬编码默认值
|
||||||
|
|
||||||
// 2K 尺寸,默认价格 $0.134
|
// 2K 尺寸,默认价格 $0.134 * 1.5 = $0.201
|
||||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||||
require.InDelta(t, 0.134, cost.ActualCost, 0.0001)
|
require.InDelta(t, 0.201, cost.ActualCost, 0.0001)
|
||||||
|
|
||||||
// 多张图片
|
// 多张图片
|
||||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0)
|
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0)
|
||||||
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.603, cost.TotalCost, 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格
|
// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格
|
||||||
@@ -63,13 +63,13 @@ func TestCalculateImageCost_RateMultiplier(t *testing.T) {
|
|||||||
|
|
||||||
// 费率倍数 1.5x
|
// 费率倍数 1.5x
|
||||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5)
|
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001) // TotalCost 不变
|
require.InDelta(t, 0.201, cost.TotalCost, 0.0001) // TotalCost = 0.134 * 1.5
|
||||||
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // ActualCost = 0.134 * 1.5
|
require.InDelta(t, 0.3015, cost.ActualCost, 0.0001) // ActualCost = 0.201 * 1.5
|
||||||
|
|
||||||
// 费率倍数 2.0x
|
// 费率倍数 2.0x
|
||||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0)
|
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0)
|
||||||
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
|
||||||
require.InDelta(t, 0.536, cost.ActualCost, 0.0001)
|
require.InDelta(t, 0.804, cost.ActualCost, 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCalculateImageCost_ZeroCount 测试 imageCount=0
|
// TestCalculateImageCost_ZeroCount 测试 imageCount=0
|
||||||
@@ -95,8 +95,8 @@ func TestCalculateImageCost_ZeroRateMultiplier(t *testing.T) {
|
|||||||
svc := &BillingService{}
|
svc := &BillingService{}
|
||||||
|
|
||||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0)
|
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||||
require.InDelta(t, 0.134, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
|
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格
|
// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格
|
||||||
@@ -127,9 +127,9 @@ func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
|
|||||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0)
|
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0)
|
||||||
require.InDelta(t, 0.10, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.10, cost.TotalCost, 0.0001)
|
||||||
|
|
||||||
// 2K 回退默认价格 $0.134
|
// 2K 回退默认价格 $0.201 (1.5倍)
|
||||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
|
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||||
|
|
||||||
// 4K 回退默认价格 $0.268 (翻倍)
|
// 4K 回退默认价格 $0.268 (翻倍)
|
||||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
|
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
|
||||||
@@ -140,10 +140,10 @@ func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
|
|||||||
func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) {
|
func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) {
|
||||||
svc := &BillingService{} // pricingService 为 nil
|
svc := &BillingService{} // pricingService 为 nil
|
||||||
|
|
||||||
// 1K 和 2K 使用相同的默认价格 $0.134
|
// 1K 默认价格 $0.134,2K 默认价格 $0.201 (1.5倍)
|
||||||
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0)
|
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
||||||
|
|
||||||
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
|
||||||
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
|
require.InDelta(t, 0.201, cost.TotalCost, 0.0001)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -262,6 +263,107 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
|
|||||||
require.Empty(t, rec.Header().Get("Set-Cookie"))
|
require.Empty(t, rec.Header().Get("Set-Cookie"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokens404PassthroughNotError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
respBody string
|
||||||
|
wantPassthrough bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "404 endpoint not found passes through as 404",
|
||||||
|
statusCode: http.StatusNotFound,
|
||||||
|
respBody: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"not_found_error"}}`,
|
||||||
|
wantPassthrough: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "404 generic not found passes through as 404",
|
||||||
|
statusCode: http.StatusNotFound,
|
||||||
|
respBody: `{"error":{"message":"resource not found","type":"not_found_error"}}`,
|
||||||
|
wantPassthrough: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "400 Invalid URL does not passthrough",
|
||||||
|
statusCode: http.StatusBadRequest,
|
||||||
|
respBody: `{"error":{"message":"Invalid URL (POST /v1/messages/count_tokens)","type":"invalid_request_error"}}`,
|
||||||
|
wantPassthrough: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "400 model error does not passthrough",
|
||||||
|
statusCode: http.StatusBadRequest,
|
||||||
|
respBody: `{"error":{"message":"model not found: claude-unknown","type":"invalid_request_error"}}`,
|
||||||
|
wantPassthrough: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "500 internal error does not passthrough",
|
||||||
|
statusCode: http.StatusInternalServerError,
|
||||||
|
respBody: `{"error":{"message":"internal error","type":"api_error"}}`,
|
||||||
|
wantPassthrough: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
|
||||||
|
|
||||||
|
body := []byte(`{"model":"claude-sonnet-4-5-20250929","messages":[{"role":"user","content":"hi"}]}`)
|
||||||
|
parsed := &ParsedRequest{Body: body, Model: "claude-sonnet-4-5-20250929"}
|
||||||
|
|
||||||
|
upstream := &anthropicHTTPUpstreamRecorder{
|
||||||
|
resp: &http.Response{
|
||||||
|
StatusCode: tt.statusCode,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(tt.respBody)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
cfg: &config.Config{
|
||||||
|
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||||
|
},
|
||||||
|
httpUpstream: upstream,
|
||||||
|
rateLimitService: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 200,
|
||||||
|
Name: "proxy-acc",
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"api_key": "sk-proxy",
|
||||||
|
"base_url": "https://proxy.example.com",
|
||||||
|
},
|
||||||
|
Extra: map[string]any{"anthropic_passthrough": true},
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
|
||||||
|
|
||||||
|
if tt.wantPassthrough {
|
||||||
|
// 返回 nil(不记录为错误),HTTP 状态码 404 + Anthropic 错误体
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
var errResp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp))
|
||||||
|
require.Equal(t, "error", errResp["type"])
|
||||||
|
errObj, ok := errResp["error"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "not_found_error", errObj["type"])
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, tt.statusCode, rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBaseURL(t *testing.T) {
|
func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBaseURL(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,60 +24,78 @@ func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) {
|
|||||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
|
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripBetaToken(t *testing.T) {
|
func TestStripBetaTokens(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
token string
|
tokens []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "token in middle",
|
name: "single token in middle",
|
||||||
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
|
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "token at start",
|
name: "single token at start",
|
||||||
header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
header: "context-1m-2025-08-07,oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "token at end",
|
name: "single token at end",
|
||||||
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07",
|
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "token not present",
|
name: "token not present",
|
||||||
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty header",
|
name: "empty header",
|
||||||
header: "",
|
header: "",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with spaces",
|
name: "with spaces",
|
||||||
header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14",
|
header: "oauth-2025-04-20, context-1m-2025-08-07 , interleaved-thinking-2025-05-14",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only token",
|
name: "only token",
|
||||||
header: "context-1m-2025-08-07",
|
header: "context-1m-2025-08-07",
|
||||||
token: "context-1m-2025-08-07",
|
tokens: []string{"context-1m-2025-08-07"},
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "nil tokens",
|
||||||
|
header: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
tokens: nil,
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple tokens removed",
|
||||||
|
header: "oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,fast-mode-2026-02-01",
|
||||||
|
tokens: []string{"context-1m-2025-08-07", "fast-mode-2026-02-01"},
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DroppedBetas removes both context-1m and fast-mode",
|
||||||
|
header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
|
||||||
|
tokens: claude.DroppedBetas,
|
||||||
|
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := stripBetaToken(tt.header, tt.token)
|
got := stripBetaTokens(tt.header, tt.tokens)
|
||||||
require.Equal(t, tt.want, got)
|
require.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -90,3 +110,29 @@ func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
|
|||||||
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
||||||
require.NotContains(t, got, "context-1m-2025-08-07")
|
require.NotContains(t, got, "context-1m-2025-08-07")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) {
|
||||||
|
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
|
||||||
|
incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20"
|
||||||
|
drop := droppedBetaSet()
|
||||||
|
|
||||||
|
got := mergeAnthropicBetaDropping(required, incoming, drop)
|
||||||
|
require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo-beta", got)
|
||||||
|
require.NotContains(t, got, "context-1m-2025-08-07")
|
||||||
|
require.NotContains(t, got, "fast-mode-2026-02-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDroppedBetaSet(t *testing.T) {
|
||||||
|
// Base set contains DroppedBetas
|
||||||
|
base := droppedBetaSet()
|
||||||
|
require.Contains(t, base, claude.BetaContext1M)
|
||||||
|
require.Contains(t, base, claude.BetaFastMode)
|
||||||
|
require.Len(t, base, len(claude.DroppedBetas))
|
||||||
|
|
||||||
|
// With extra tokens
|
||||||
|
extended := droppedBetaSet(claude.BetaClaudeCode)
|
||||||
|
require.Contains(t, extended, claude.BetaContext1M)
|
||||||
|
require.Contains(t, extended, claude.BetaFastMode)
|
||||||
|
require.Contains(t, extended, claude.BetaClaudeCode)
|
||||||
|
require.Len(t, extended, len(claude.DroppedBetas)+1)
|
||||||
|
}
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ type ForwardResult struct {
|
|||||||
FirstTokenMs *int // 首字时间(流式请求)
|
FirstTokenMs *int // 首字时间(流式请求)
|
||||||
ClientDisconnect bool // 客户端是否在流式传输过程中断开
|
ClientDisconnect bool // 客户端是否在流式传输过程中断开
|
||||||
|
|
||||||
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
|
// 图片生成计费字段(图片生成模型使用)
|
||||||
ImageCount int // 生成的图片数量
|
ImageCount int // 生成的图片数量
|
||||||
ImageSize string // 图片尺寸 "1K", "2K", "4K"
|
ImageSize string // 图片尺寸 "1K", "2K", "4K"
|
||||||
|
|
||||||
@@ -4425,12 +4425,12 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
// messages requests typically use only oauth + interleaved-thinking.
|
// messages requests typically use only oauth + interleaved-thinking.
|
||||||
// Also drop claude-code beta if a downstream client added it.
|
// Also drop claude-code beta if a downstream client added it.
|
||||||
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
||||||
drop := map[string]struct{}{claude.BetaClaudeCode: {}, claude.BetaContext1M: {}}
|
drop := droppedBetaSet(claude.BetaClaudeCode)
|
||||||
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
||||||
} else {
|
} else {
|
||||||
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
// Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
|
||||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||||
req.Header.Set("anthropic-beta", stripBetaToken(s.getBetaHeader(modelID, clientBetaHeader), claude.BetaContext1M))
|
req.Header.Set("anthropic-beta", stripBetaTokens(s.getBetaHeader(modelID, clientBetaHeader), claude.DroppedBetas))
|
||||||
}
|
}
|
||||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
||||||
@@ -4584,23 +4584,45 @@ func mergeAnthropicBetaDropping(required []string, incoming string, drop map[str
|
|||||||
return strings.Join(out, ",")
|
return strings.Join(out, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripBetaToken removes a single beta token from a comma-separated header value.
|
// stripBetaTokens removes the given beta tokens from a comma-separated header value.
|
||||||
// It short-circuits when the token is not present to avoid unnecessary allocations.
|
func stripBetaTokens(header string, tokens []string) string {
|
||||||
func stripBetaToken(header, token string) string {
|
if header == "" || len(tokens) == 0 {
|
||||||
if !strings.Contains(header, token) {
|
|
||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
out := make([]string, 0, 8)
|
drop := make(map[string]struct{}, len(tokens))
|
||||||
for _, p := range strings.Split(header, ",") {
|
for _, t := range tokens {
|
||||||
|
drop[t] = struct{}{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(header, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
if p == "" || p == token {
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := drop[p]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
|
if len(out) == len(parts) {
|
||||||
|
return header // no change, avoid allocation
|
||||||
|
}
|
||||||
return strings.Join(out, ",")
|
return strings.Join(out, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
|
||||||
|
func droppedBetaSet(extra ...string) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{}, len(claude.DroppedBetas)+len(extra))
|
||||||
|
for _, t := range claude.DroppedBetas {
|
||||||
|
m[t] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, t := range extra {
|
||||||
|
m[t] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
||||||
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
||||||
// headers when using Claude Code-scoped OAuth credentials.
|
// headers when using Claude Code-scoped OAuth credentials.
|
||||||
@@ -5993,9 +6015,10 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
|||||||
body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antigravity 账户不支持 count_tokens 转发,直接返回空值
|
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
|
||||||
|
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity {
|
||||||
c.JSON(http.StatusOK, gin.H{"input_tokens": 0})
|
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6199,6 +6222,17 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
|||||||
|
|
||||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||||
|
|
||||||
|
// 中转站不支持 count_tokens 端点时(404),返回 404 让客户端 fallback 到本地估算。
|
||||||
|
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
logger.LegacyPrintf("service.gateway",
|
||||||
|
"[count_tokens] Upstream does not support count_tokens (404), returning 404: account=%d name=%s msg=%s",
|
||||||
|
account.ID, account.Name, truncateString(upstreamMsg, 512))
|
||||||
|
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported by upstream")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
upstreamDetail := ""
|
upstreamDetail := ""
|
||||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||||
@@ -6375,7 +6409,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
|
|
||||||
incomingBeta := req.Header.Get("anthropic-beta")
|
incomingBeta := req.Header.Get("anthropic-beta")
|
||||||
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting}
|
||||||
drop := map[string]struct{}{claude.BetaContext1M: {}}
|
drop := droppedBetaSet()
|
||||||
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop))
|
||||||
} else {
|
} else {
|
||||||
clientBetaHeader := req.Header.Get("anthropic-beta")
|
clientBetaHeader := req.Header.Get("anthropic-beta")
|
||||||
@@ -6386,7 +6420,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
||||||
beta = beta + "," + claude.BetaTokenCounting
|
beta = beta + "," + claude.BetaTokenCounting
|
||||||
}
|
}
|
||||||
req.Header.Set("anthropic-beta", stripBetaToken(beta, claude.BetaContext1M))
|
req.Header.Set("anthropic-beta", stripBetaTokens(beta, claude.DroppedBetas))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type GeminiOAuthService struct {
|
|||||||
proxyRepo ProxyRepository
|
proxyRepo ProxyRepository
|
||||||
oauthClient GeminiOAuthClient
|
oauthClient GeminiOAuthClient
|
||||||
codeAssist GeminiCliCodeAssistClient
|
codeAssist GeminiCliCodeAssistClient
|
||||||
|
driveClient geminicli.DriveClient
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ func NewGeminiOAuthService(
|
|||||||
proxyRepo ProxyRepository,
|
proxyRepo ProxyRepository,
|
||||||
oauthClient GeminiOAuthClient,
|
oauthClient GeminiOAuthClient,
|
||||||
codeAssist GeminiCliCodeAssistClient,
|
codeAssist GeminiCliCodeAssistClient,
|
||||||
|
driveClient geminicli.DriveClient,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *GeminiOAuthService {
|
) *GeminiOAuthService {
|
||||||
return &GeminiOAuthService{
|
return &GeminiOAuthService{
|
||||||
@@ -73,6 +75,7 @@ func NewGeminiOAuthService(
|
|||||||
proxyRepo: proxyRepo,
|
proxyRepo: proxyRepo,
|
||||||
oauthClient: oauthClient,
|
oauthClient: oauthClient,
|
||||||
codeAssist: codeAssist,
|
codeAssist: codeAssist,
|
||||||
|
driveClient: driveClient,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,9 +365,8 @@ func (s *GeminiOAuthService) FetchGoogleOneTier(ctx context.Context, accessToken
|
|||||||
|
|
||||||
// Use Drive API to infer tier from storage quota (requires drive.readonly scope)
|
// Use Drive API to infer tier from storage quota (requires drive.readonly scope)
|
||||||
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Calling Drive API for storage quota...")
|
logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Calling Drive API for storage quota...")
|
||||||
driveClient := geminicli.NewDriveClient()
|
|
||||||
|
|
||||||
storageInfo, err := driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
|
storageInfo, err := s.driveClient.GetStorageQuota(ctx, accessToken, proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's a 403 (scope not granted)
|
// Check if it's a 403 (scope not granted)
|
||||||
if strings.Contains(err.Error(), "status 403") {
|
if strings.Contains(err.Error(), "status 403") {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestGeminiOAuthService_GenerateAuthURL_RedirectURIStrategy(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, tt.cfg)
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, tt.cfg)
|
||||||
got, err := svc.GenerateAuthURL(context.Background(), nil, "https://example.com/auth/callback", tt.projectID, tt.oauthType, "")
|
got, err := svc.GenerateAuthURL(context.Background(), nil, "https://example.com/auth/callback", tt.projectID, tt.oauthType, "")
|
||||||
if tt.wantErrSubstr != "" {
|
if tt.wantErrSubstr != "" {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -487,7 +487,7 @@ func TestIsNonRetryableGeminiOAuthError(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_BuildAccountCredentials(t *testing.T) {
|
func TestGeminiOAuthService_BuildAccountCredentials(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
t.Run("完整字段", func(t *testing.T) {
|
t.Run("完整字段", func(t *testing.T) {
|
||||||
@@ -687,7 +687,7 @@ func TestGeminiOAuthService_GetOAuthConfig(t *testing.T) {
|
|||||||
tt := tt
|
tt := tt
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, tt.cfg)
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, tt.cfg)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
result := svc.GetOAuthConfig()
|
result := svc.GetOAuthConfig()
|
||||||
@@ -709,7 +709,7 @@ func TestGeminiOAuthService_GetOAuthConfig(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_Stop_NoPanic(t *testing.T) {
|
func TestGeminiOAuthService_Stop_NoPanic(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
|
|
||||||
// 调用 Stop 不应 panic
|
// 调用 Stop 不应 panic
|
||||||
svc.Stop()
|
svc.Stop()
|
||||||
@@ -806,6 +806,18 @@ func (m *mockGeminiProxyRepo) ListAccountSummariesByProxyID(ctx context.Context,
|
|||||||
panic("not impl")
|
panic("not impl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDriveClient implements geminicli.DriveClient for tests.
|
||||||
|
type mockDriveClient struct {
|
||||||
|
getStorageQuotaFunc func(ctx context.Context, accessToken, proxyURL string) (*geminicli.DriveStorageInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDriveClient) GetStorageQuota(ctx context.Context, accessToken, proxyURL string) (*geminicli.DriveStorageInfo, error) {
|
||||||
|
if m.getStorageQuotaFunc != nil {
|
||||||
|
return m.getStorageQuotaFunc(ctx, accessToken, proxyURL)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("drive API not available in test")
|
||||||
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// 新增测试:GeminiOAuthService.RefreshToken(含重试逻辑)
|
// 新增测试:GeminiOAuthService.RefreshToken(含重试逻辑)
|
||||||
// =====================
|
// =====================
|
||||||
@@ -825,7 +837,7 @@ func TestGeminiOAuthService_RefreshToken_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
info, err := svc.RefreshToken(context.Background(), "code_assist", "old-refresh", "")
|
info, err := svc.RefreshToken(context.Background(), "code_assist", "old-refresh", "")
|
||||||
@@ -852,7 +864,7 @@ func TestGeminiOAuthService_RefreshToken_NonRetryableError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
_, err := svc.RefreshToken(context.Background(), "code_assist", "revoked-token", "")
|
_, err := svc.RefreshToken(context.Background(), "code_assist", "revoked-token", "")
|
||||||
@@ -881,7 +893,7 @@ func TestGeminiOAuthService_RefreshToken_RetryableError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
info, err := svc.RefreshToken(context.Background(), "code_assist", "rt", "")
|
info, err := svc.RefreshToken(context.Background(), "code_assist", "rt", "")
|
||||||
@@ -903,7 +915,7 @@ func TestGeminiOAuthService_RefreshToken_RetryableError(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_RefreshAccountToken_NotGeminiOAuth(t *testing.T) {
|
func TestGeminiOAuthService_RefreshAccountToken_NotGeminiOAuth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -923,7 +935,7 @@ func TestGeminiOAuthService_RefreshAccountToken_NotGeminiOAuth(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_RefreshAccountToken_NoRefreshToken(t *testing.T) {
|
func TestGeminiOAuthService_RefreshAccountToken_NoRefreshToken(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -958,7 +970,7 @@ func TestGeminiOAuthService_RefreshAccountToken_AIStudio(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -997,7 +1009,7 @@ func TestGeminiOAuthService_RefreshAccountToken_CodeAssist_WithProjectID(t *test
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1042,7 +1054,7 @@ func TestGeminiOAuthService_RefreshAccountToken_DefaultOAuthType(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
// 无 oauth_type 凭据的旧账号
|
// 无 oauth_type 凭据的旧账号
|
||||||
@@ -1090,7 +1102,7 @@ func TestGeminiOAuthService_RefreshAccountToken_WithProxy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(proxyRepo, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(proxyRepo, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
proxyID := int64(5)
|
proxyID := int64(5)
|
||||||
@@ -1132,7 +1144,7 @@ func TestGeminiOAuthService_RefreshAccountToken_CodeAssist_NoProjectID_AutoDetec
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, codeAssist, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, codeAssist, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1181,7 +1193,7 @@ func TestGeminiOAuthService_RefreshAccountToken_CodeAssist_NoProjectID_FailsEmpt
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, codeAssist, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, codeAssist, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1214,7 +1226,7 @@ func TestGeminiOAuthService_RefreshAccountToken_GoogleOne_FreshCache(t *testing.
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1254,7 +1266,7 @@ func TestGeminiOAuthService_RefreshAccountToken_GoogleOne_NoTierID_DefaultsFree(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &mockDriveClient{}, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1308,7 +1320,7 @@ func TestGeminiOAuthService_RefreshAccountToken_UnauthorizedClient_Fallback(t *t
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, cfg)
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, cfg)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1341,7 +1353,7 @@ func TestGeminiOAuthService_RefreshAccountToken_UnauthorizedClient_NoFallback(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 无自定义 OAuth 客户端,无法 fallback
|
// 无自定义 OAuth 客户端,无法 fallback
|
||||||
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, &config.Config{})
|
svc := NewGeminiOAuthService(&mockGeminiProxyRepo{}, client, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
account := &Account{
|
account := &Account{
|
||||||
@@ -1370,7 +1382,7 @@ func TestGeminiOAuthService_RefreshAccountToken_UnauthorizedClient_NoFallback(t
|
|||||||
func TestGeminiOAuthService_ExchangeCode_SessionNotFound(t *testing.T) {
|
func TestGeminiOAuthService_ExchangeCode_SessionNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
_, err := svc.ExchangeCode(context.Background(), &GeminiExchangeCodeInput{
|
_, err := svc.ExchangeCode(context.Background(), &GeminiExchangeCodeInput{
|
||||||
@@ -1389,7 +1401,7 @@ func TestGeminiOAuthService_ExchangeCode_SessionNotFound(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_ExchangeCode_InvalidState(t *testing.T) {
|
func TestGeminiOAuthService_ExchangeCode_InvalidState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
// 手动创建 session(必须设置 CreatedAt,否则会因 TTL 过期被拒绝)
|
// 手动创建 session(必须设置 CreatedAt,否则会因 TTL 过期被拒绝)
|
||||||
@@ -1416,7 +1428,7 @@ func TestGeminiOAuthService_ExchangeCode_InvalidState(t *testing.T) {
|
|||||||
func TestGeminiOAuthService_ExchangeCode_EmptyState(t *testing.T) {
|
func TestGeminiOAuthService_ExchangeCode_EmptyState(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
svc := NewGeminiOAuthService(nil, nil, nil, &config.Config{})
|
svc := NewGeminiOAuthService(nil, nil, nil, nil, &config.Config{})
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
svc.sessionStore.Set("test-session", &geminicli.OAuthSession{
|
svc.sessionStore.Set("test-session", &geminicli.OAuthSession{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,515 +0,0 @@
|
|||||||
//go:build unit
|
|
||||||
|
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------- 辅助解析函数(复制生产代码中的 gjson 解析逻辑,用于单元测试) ----------
|
|
||||||
|
|
||||||
// testParseUploadOrCreateTaskID 模拟 UploadImage / CreateImageTask / CreateVideoTask 中
|
|
||||||
// 用 gjson.GetBytes(respBody, "id") 提取 id 的逻辑。
|
|
||||||
func testParseUploadOrCreateTaskID(respBody []byte) (string, error) {
|
|
||||||
id := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
|
|
||||||
if id == "" {
|
|
||||||
return "", assert.AnError // 占位错误,表示 "missing id"
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// testParseFetchRecentImageTask 模拟 fetchRecentImageTask 中的 gjson.ForEach 解析逻辑。
|
|
||||||
func testParseFetchRecentImageTask(respBody []byte, taskID string) (*SoraImageTaskStatus, bool) {
|
|
||||||
var found *SoraImageTaskStatus
|
|
||||||
gjson.GetBytes(respBody, "task_responses").ForEach(func(_, item gjson.Result) bool {
|
|
||||||
if item.Get("id").String() != taskID {
|
|
||||||
return true // continue
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(item.Get("status").String())
|
|
||||||
progress := item.Get("progress_pct").Float()
|
|
||||||
var urls []string
|
|
||||||
item.Get("generations").ForEach(func(_, gen gjson.Result) bool {
|
|
||||||
if u := strings.TrimSpace(gen.Get("url").String()); u != "" {
|
|
||||||
urls = append(urls, u)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
found = &SoraImageTaskStatus{
|
|
||||||
ID: taskID,
|
|
||||||
Status: status,
|
|
||||||
ProgressPct: progress,
|
|
||||||
URLs: urls,
|
|
||||||
}
|
|
||||||
return false // break
|
|
||||||
})
|
|
||||||
if found != nil {
|
|
||||||
return found, true
|
|
||||||
}
|
|
||||||
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// testParseGetVideoTaskPending 模拟 GetVideoTask 中解析 pending 列表的逻辑。
|
|
||||||
func testParseGetVideoTaskPending(respBody []byte, taskID string) (*SoraVideoTaskStatus, bool) {
|
|
||||||
pendingResult := gjson.ParseBytes(respBody)
|
|
||||||
if !pendingResult.IsArray() {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
var pendingFound *SoraVideoTaskStatus
|
|
||||||
pendingResult.ForEach(func(_, task gjson.Result) bool {
|
|
||||||
if task.Get("id").String() != taskID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
progress := 0
|
|
||||||
if v := task.Get("progress_pct"); v.Exists() {
|
|
||||||
progress = int(v.Float() * 100)
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(task.Get("status").String())
|
|
||||||
pendingFound = &SoraVideoTaskStatus{
|
|
||||||
ID: taskID,
|
|
||||||
Status: status,
|
|
||||||
ProgressPct: progress,
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if pendingFound != nil {
|
|
||||||
return pendingFound, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// testParseGetVideoTaskDrafts 模拟 GetVideoTask 中解析 drafts 列表的逻辑。
|
|
||||||
func testParseGetVideoTaskDrafts(respBody []byte, taskID string) (*SoraVideoTaskStatus, bool) {
|
|
||||||
var draftFound *SoraVideoTaskStatus
|
|
||||||
gjson.GetBytes(respBody, "items").ForEach(func(_, draft gjson.Result) bool {
|
|
||||||
if draft.Get("task_id").String() != taskID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
kind := strings.TrimSpace(draft.Get("kind").String())
|
|
||||||
reason := strings.TrimSpace(draft.Get("reason_str").String())
|
|
||||||
if reason == "" {
|
|
||||||
reason = strings.TrimSpace(draft.Get("markdown_reason_str").String())
|
|
||||||
}
|
|
||||||
urlStr := strings.TrimSpace(draft.Get("downloadable_url").String())
|
|
||||||
if urlStr == "" {
|
|
||||||
urlStr = strings.TrimSpace(draft.Get("url").String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if kind == "sora_content_violation" || reason != "" || urlStr == "" {
|
|
||||||
msg := reason
|
|
||||||
if msg == "" {
|
|
||||||
msg = "Content violates guardrails"
|
|
||||||
}
|
|
||||||
draftFound = &SoraVideoTaskStatus{
|
|
||||||
ID: taskID,
|
|
||||||
Status: "failed",
|
|
||||||
ErrorMsg: msg,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
draftFound = &SoraVideoTaskStatus{
|
|
||||||
ID: taskID,
|
|
||||||
Status: "completed",
|
|
||||||
URLs: []string{urlStr},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
if draftFound != nil {
|
|
||||||
return draftFound, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Test 1: TestSoraParseUploadResponse =====================
|
|
||||||
|
|
||||||
func TestSoraParseUploadResponse(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
wantID string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "正常 id",
|
|
||||||
body: `{"id":"file-abc123","status":"uploaded"}`,
|
|
||||||
wantID: "file-abc123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 id",
|
|
||||||
body: `{"id":"","status":"uploaded"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无 id 字段",
|
|
||||||
body: `{"status":"uploaded"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "id 全为空白",
|
|
||||||
body: `{"id":" ","status":"uploaded"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "id 前后有空白",
|
|
||||||
body: `{"id":" file-trimmed ","status":"uploaded"}`,
|
|
||||||
wantID: "file-trimmed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 JSON 对象",
|
|
||||||
body: `{}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
id, err := testParseUploadOrCreateTaskID([]byte(tt.body))
|
|
||||||
if tt.wantErr {
|
|
||||||
require.Error(t, err, "应返回错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tt.wantID, id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Test 2: TestSoraParseCreateTaskResponse =====================
|
|
||||||
|
|
||||||
func TestSoraParseCreateTaskResponse(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
wantID string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "正常任务 id",
|
|
||||||
body: `{"id":"task-123"}`,
|
|
||||||
wantID: "task-123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "缺失 id",
|
|
||||||
body: `{"status":"created"}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 id",
|
|
||||||
body: `{"id":" "}`,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "id 为数字(gjson 转字符串)",
|
|
||||||
body: `{"id":123}`,
|
|
||||||
wantID: "123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "id 含特殊字符",
|
|
||||||
body: `{"id":"task-abc-def-456-ghi"}`,
|
|
||||||
wantID: "task-abc-def-456-ghi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "额外字段不影响解析",
|
|
||||||
body: `{"id":"task-999","type":"image_gen","extra":"data"}`,
|
|
||||||
wantID: "task-999",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
id, err := testParseUploadOrCreateTaskID([]byte(tt.body))
|
|
||||||
if tt.wantErr {
|
|
||||||
require.Error(t, err, "应返回错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tt.wantID, id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Test 3: TestSoraParseFetchRecentImageTask =====================
|
|
||||||
|
|
||||||
func TestSoraParseFetchRecentImageTask(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
taskID string
|
|
||||||
wantFound bool
|
|
||||||
wantStatus string
|
|
||||||
wantProgress float64
|
|
||||||
wantURLs []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "匹配已完成任务",
|
|
||||||
body: `{"task_responses":[{"id":"task-1","status":"completed","progress_pct":1.0,"generations":[{"url":"https://example.com/img.png"}]}]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "completed",
|
|
||||||
wantProgress: 1.0,
|
|
||||||
wantURLs: []string{"https://example.com/img.png"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "匹配处理中任务",
|
|
||||||
body: `{"task_responses":[{"id":"task-2","status":"processing","progress_pct":0.5,"generations":[]}]}`,
|
|
||||||
taskID: "task-2",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "processing",
|
|
||||||
wantProgress: 0.5,
|
|
||||||
wantURLs: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无匹配任务",
|
|
||||||
body: `{"task_responses":[{"id":"other","status":"completed"}]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
wantStatus: "processing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 task_responses",
|
|
||||||
body: `{"task_responses":[]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
wantStatus: "processing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "缺少 task_responses 字段",
|
|
||||||
body: `{"other":"data"}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
wantStatus: "processing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个任务中精准匹配",
|
|
||||||
body: `{"task_responses":[{"id":"task-a","status":"completed","progress_pct":1.0,"generations":[{"url":"https://a.com/1.png"}]},{"id":"task-b","status":"processing","progress_pct":0.3,"generations":[]},{"id":"task-c","status":"failed","progress_pct":0}]}`,
|
|
||||||
taskID: "task-b",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "processing",
|
|
||||||
wantProgress: 0.3,
|
|
||||||
wantURLs: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个 generations",
|
|
||||||
body: `{"task_responses":[{"id":"task-m","status":"completed","progress_pct":1.0,"generations":[{"url":"https://a.com/1.png"},{"url":"https://a.com/2.png"},{"url":""}]}]}`,
|
|
||||||
taskID: "task-m",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "completed",
|
|
||||||
wantProgress: 1.0,
|
|
||||||
wantURLs: []string{"https://a.com/1.png", "https://a.com/2.png"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
status, found := testParseFetchRecentImageTask([]byte(tt.body), tt.taskID)
|
|
||||||
require.Equal(t, tt.wantFound, found, "found 不匹配")
|
|
||||||
require.NotNil(t, status)
|
|
||||||
require.Equal(t, tt.taskID, status.ID)
|
|
||||||
require.Equal(t, tt.wantStatus, status.Status)
|
|
||||||
if tt.wantFound {
|
|
||||||
require.InDelta(t, tt.wantProgress, status.ProgressPct, 0.001, "进度不匹配")
|
|
||||||
require.Equal(t, tt.wantURLs, status.URLs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Test 4: TestSoraParseGetVideoTaskPending =====================
|
|
||||||
|
|
||||||
func TestSoraParseGetVideoTaskPending(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
taskID string
|
|
||||||
wantFound bool
|
|
||||||
wantStatus string
|
|
||||||
wantProgress int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "匹配 pending 任务",
|
|
||||||
body: `[{"id":"task-1","status":"processing","progress_pct":0.5}]`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "processing",
|
|
||||||
wantProgress: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "进度为 0",
|
|
||||||
body: `[{"id":"task-2","status":"queued","progress_pct":0}]`,
|
|
||||||
taskID: "task-2",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "queued",
|
|
||||||
wantProgress: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "进度为 1(100%)",
|
|
||||||
body: `[{"id":"task-3","status":"completing","progress_pct":1.0}]`,
|
|
||||||
taskID: "task-3",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "completing",
|
|
||||||
wantProgress: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空数组",
|
|
||||||
body: `[]`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无匹配 id",
|
|
||||||
body: `[{"id":"task-other","status":"processing","progress_pct":0.3}]`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个任务精准匹配",
|
|
||||||
body: `[{"id":"task-a","status":"processing","progress_pct":0.2},{"id":"task-b","status":"queued","progress_pct":0},{"id":"task-c","status":"processing","progress_pct":0.8}]`,
|
|
||||||
taskID: "task-c",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "processing",
|
|
||||||
wantProgress: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "非数组 JSON",
|
|
||||||
body: `{"id":"task-1","status":"processing"}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无 progress_pct 字段",
|
|
||||||
body: `[{"id":"task-4","status":"pending"}]`,
|
|
||||||
taskID: "task-4",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "pending",
|
|
||||||
wantProgress: 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
status, found := testParseGetVideoTaskPending([]byte(tt.body), tt.taskID)
|
|
||||||
require.Equal(t, tt.wantFound, found, "found 不匹配")
|
|
||||||
if tt.wantFound {
|
|
||||||
require.NotNil(t, status)
|
|
||||||
require.Equal(t, tt.taskID, status.ID)
|
|
||||||
require.Equal(t, tt.wantStatus, status.Status)
|
|
||||||
require.Equal(t, tt.wantProgress, status.ProgressPct)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== Test 5: TestSoraParseGetVideoTaskDrafts =====================
|
|
||||||
|
|
||||||
func TestSoraParseGetVideoTaskDrafts(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
taskID string
|
|
||||||
wantFound bool
|
|
||||||
wantStatus string
|
|
||||||
wantURLs []string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "正常完成的视频",
|
|
||||||
body: `{"items":[{"task_id":"task-1","kind":"video","downloadable_url":"https://example.com/video.mp4"}]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "completed",
|
|
||||||
wantURLs: []string{"https://example.com/video.mp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "使用 url 字段回退",
|
|
||||||
body: `{"items":[{"task_id":"task-2","kind":"video","url":"https://example.com/fallback.mp4"}]}`,
|
|
||||||
taskID: "task-2",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "completed",
|
|
||||||
wantURLs: []string{"https://example.com/fallback.mp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "内容违规",
|
|
||||||
body: `{"items":[{"task_id":"task-3","kind":"sora_content_violation","reason_str":"Content policy violation"}]}`,
|
|
||||||
taskID: "task-3",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Content policy violation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "内容违规 - markdown_reason_str 回退",
|
|
||||||
body: `{"items":[{"task_id":"task-4","kind":"sora_content_violation","markdown_reason_str":"Markdown reason"}]}`,
|
|
||||||
taskID: "task-4",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Markdown reason",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "内容违规 - 无 reason 使用默认消息",
|
|
||||||
body: `{"items":[{"task_id":"task-5","kind":"sora_content_violation"}]}`,
|
|
||||||
taskID: "task-5",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Content violates guardrails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "有 reason_str 但非 violation kind(仍判定失败)",
|
|
||||||
body: `{"items":[{"task_id":"task-6","kind":"video","reason_str":"Some error occurred"}]}`,
|
|
||||||
taskID: "task-6",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Some error occurred",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 URL 判定为失败",
|
|
||||||
body: `{"items":[{"task_id":"task-7","kind":"video","downloadable_url":"","url":""}]}`,
|
|
||||||
taskID: "task-7",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Content violates guardrails",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "无匹配 task_id",
|
|
||||||
body: `{"items":[{"task_id":"task-other","kind":"video","downloadable_url":"https://example.com/video.mp4"}]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "空 items",
|
|
||||||
body: `{"items":[]}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "缺少 items 字段",
|
|
||||||
body: `{"other":"data"}`,
|
|
||||||
taskID: "task-1",
|
|
||||||
wantFound: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多个 items 精准匹配",
|
|
||||||
body: `{"items":[{"task_id":"task-a","kind":"video","downloadable_url":"https://a.com/a.mp4"},{"task_id":"task-b","kind":"sora_content_violation","reason_str":"Bad content"},{"task_id":"task-c","kind":"video","downloadable_url":"https://c.com/c.mp4"}]}`,
|
|
||||||
taskID: "task-b",
|
|
||||||
wantFound: true,
|
|
||||||
wantStatus: "failed",
|
|
||||||
wantErr: "Bad content",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
status, found := testParseGetVideoTaskDrafts([]byte(tt.body), tt.taskID)
|
|
||||||
require.Equal(t, tt.wantFound, found, "found 不匹配")
|
|
||||||
if !tt.wantFound {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NotNil(t, status)
|
|
||||||
require.Equal(t, tt.taskID, status.ID)
|
|
||||||
require.Equal(t, tt.wantStatus, status.Status)
|
|
||||||
if tt.wantErr != "" {
|
|
||||||
require.Equal(t, tt.wantErr, status.ErrorMsg)
|
|
||||||
}
|
|
||||||
if tt.wantURLs != nil {
|
|
||||||
require.Equal(t, tt.wantURLs, status.URLs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,260 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
|
||||||
)
|
|
||||||
|
|
||||||
const soraCurlCFFISidecarDefaultTimeoutSeconds = 60
|
|
||||||
|
|
||||||
type soraCurlCFFISidecarRequest struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Headers map[string][]string `json:"headers,omitempty"`
|
|
||||||
BodyBase64 string `json:"body_base64,omitempty"`
|
|
||||||
ProxyURL string `json:"proxy_url,omitempty"`
|
|
||||||
SessionKey string `json:"session_key,omitempty"`
|
|
||||||
Impersonate string `json:"impersonate,omitempty"`
|
|
||||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type soraCurlCFFISidecarResponse struct {
|
|
||||||
StatusCode int `json:"status_code"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
Headers map[string]any `json:"headers"`
|
|
||||||
BodyBase64 string `json:"body_base64"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) doHTTPViaCurlCFFISidecar(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
|
||||||
if req == nil || req.URL == nil {
|
|
||||||
return nil, errors.New("request url is nil")
|
|
||||||
}
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return nil, errors.New("sora curl_cffi sidecar config is nil")
|
|
||||||
}
|
|
||||||
if !c.cfg.Sora.Client.CurlCFFISidecar.Enabled {
|
|
||||||
return nil, errors.New("sora curl_cffi sidecar is disabled")
|
|
||||||
}
|
|
||||||
endpoint := c.curlCFFISidecarEndpoint()
|
|
||||||
if endpoint == "" {
|
|
||||||
return nil, errors.New("sora curl_cffi sidecar base_url is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := readAndRestoreRequestBody(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar read request body failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
headers := make(map[string][]string, len(req.Header)+1)
|
|
||||||
for key, vals := range req.Header {
|
|
||||||
copied := make([]string, len(vals))
|
|
||||||
copy(copied, vals)
|
|
||||||
headers[key] = copied
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.Host) != "" {
|
|
||||||
if _, ok := headers["Host"]; !ok {
|
|
||||||
headers["Host"] = []string{req.Host}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := soraCurlCFFISidecarRequest{
|
|
||||||
Method: req.Method,
|
|
||||||
URL: req.URL.String(),
|
|
||||||
Headers: headers,
|
|
||||||
ProxyURL: strings.TrimSpace(proxyURL),
|
|
||||||
SessionKey: c.sidecarSessionKey(account, proxyURL),
|
|
||||||
Impersonate: c.curlCFFIImpersonate(),
|
|
||||||
TimeoutSeconds: c.curlCFFISidecarTimeoutSeconds(),
|
|
||||||
}
|
|
||||||
if len(bodyBytes) > 0 {
|
|
||||||
payload.BodyBase64 = base64.StdEncoding.EncodeToString(bodyBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar marshal request failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sidecarReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, endpoint, bytes.NewReader(encoded))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar build request failed: %w", err)
|
|
||||||
}
|
|
||||||
sidecarReq.Header.Set("Content-Type", "application/json")
|
|
||||||
sidecarReq.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
httpClient := &http.Client{Timeout: time.Duration(payload.TimeoutSeconds) * time.Second}
|
|
||||||
sidecarResp, err := httpClient.Do(sidecarReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = sidecarResp.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
sidecarRespBody, err := io.ReadAll(io.LimitReader(sidecarResp.Body, 8<<20))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar read response failed: %w", err)
|
|
||||||
}
|
|
||||||
if sidecarResp.StatusCode != http.StatusOK {
|
|
||||||
redacted := truncateForLog([]byte(logredact.RedactText(string(sidecarRespBody))), 512)
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar http status=%d body=%s", sidecarResp.StatusCode, redacted)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payloadResp soraCurlCFFISidecarResponse
|
|
||||||
if err := json.Unmarshal(sidecarRespBody, &payloadResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar parse response failed: %w", err)
|
|
||||||
}
|
|
||||||
if msg := strings.TrimSpace(payloadResp.Error); msg != "" {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar upstream error: %s", msg)
|
|
||||||
}
|
|
||||||
statusCode := payloadResp.StatusCode
|
|
||||||
if statusCode <= 0 {
|
|
||||||
statusCode = payloadResp.Status
|
|
||||||
}
|
|
||||||
if statusCode <= 0 {
|
|
||||||
return nil, errors.New("sora curl_cffi sidecar response missing status code")
|
|
||||||
}
|
|
||||||
|
|
||||||
responseBody := []byte(payloadResp.Body)
|
|
||||||
if strings.TrimSpace(payloadResp.BodyBase64) != "" {
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(payloadResp.BodyBase64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sora curl_cffi sidecar decode body failed: %w", err)
|
|
||||||
}
|
|
||||||
responseBody = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
respHeaders := make(http.Header)
|
|
||||||
for key, rawVal := range payloadResp.Headers {
|
|
||||||
for _, v := range convertSidecarHeaderValue(rawVal) {
|
|
||||||
respHeaders.Add(key, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: statusCode,
|
|
||||||
Header: respHeaders,
|
|
||||||
Body: io.NopCloser(bytes.NewReader(responseBody)),
|
|
||||||
ContentLength: int64(len(responseBody)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readAndRestoreRequestBody(req *http.Request) ([]byte, error) {
|
|
||||||
if req == nil || req.Body == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
bodyBytes, err := io.ReadAll(req.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = req.Body.Close()
|
|
||||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
||||||
req.ContentLength = int64(len(bodyBytes))
|
|
||||||
return bodyBytes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) curlCFFISidecarEndpoint() string {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.BaseURL)
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
if path := strings.TrimSpace(parsed.Path); path == "" || path == "/" {
|
|
||||||
parsed.Path = "/request"
|
|
||||||
}
|
|
||||||
return parsed.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) curlCFFISidecarTimeoutSeconds() int {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return soraCurlCFFISidecarDefaultTimeoutSeconds
|
|
||||||
}
|
|
||||||
timeoutSeconds := c.cfg.Sora.Client.CurlCFFISidecar.TimeoutSeconds
|
|
||||||
if timeoutSeconds <= 0 {
|
|
||||||
return soraCurlCFFISidecarDefaultTimeoutSeconds
|
|
||||||
}
|
|
||||||
return timeoutSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) curlCFFIImpersonate() string {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return "chrome131"
|
|
||||||
}
|
|
||||||
impersonate := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.Impersonate)
|
|
||||||
if impersonate == "" {
|
|
||||||
return "chrome131"
|
|
||||||
}
|
|
||||||
return impersonate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) sidecarSessionReuseEnabled() bool {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return c.cfg.Sora.Client.CurlCFFISidecar.SessionReuseEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) sidecarSessionTTLSeconds() int {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return 3600
|
|
||||||
}
|
|
||||||
ttl := c.cfg.Sora.Client.CurlCFFISidecar.SessionTTLSeconds
|
|
||||||
if ttl < 0 {
|
|
||||||
return 3600
|
|
||||||
}
|
|
||||||
return ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertSidecarHeaderValue(raw any) []string {
|
|
||||||
switch val := raw.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
if strings.TrimSpace(val) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{val}
|
|
||||||
case []any:
|
|
||||||
out := make([]string, 0, len(val))
|
|
||||||
for _, item := range val {
|
|
||||||
s := strings.TrimSpace(fmt.Sprint(item))
|
|
||||||
if s != "" {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []string:
|
|
||||||
out := make([]string, 0, len(val))
|
|
||||||
for _, item := range val {
|
|
||||||
if strings.TrimSpace(item) != "" {
|
|
||||||
out = append(out, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
default:
|
|
||||||
s := strings.TrimSpace(fmt.Sprint(val))
|
|
||||||
if s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{s}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
|
"math/rand"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -669,7 +671,7 @@ func processSoraCharacterUsername(usernameHint string) string {
|
|||||||
if usernameHint == "" {
|
if usernameHint == "" {
|
||||||
usernameHint = "character"
|
usernameHint = "character"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%d", usernameHint, soraRandInt(900)+100)
|
return fmt.Sprintf("%s%d", usernameHint, rand.Intn(900)+100)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SoraGatewayService) resolveWatermarkFreeURL(ctx context.Context, account *Account, generationID string, opts soraWatermarkOptions) (string, string, error) {
|
func (s *SoraGatewayService) resolveWatermarkFreeURL(ctx context.Context, account *Account, generationID string, opts soraWatermarkOptions) (string, string, error) {
|
||||||
@@ -829,7 +831,7 @@ func (s *SoraGatewayService) writeSoraStream(c *gin.Context, model, content stri
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
encoded, _ := json.Marshal(chunk)
|
encoded, _ := jsonMarshalRaw(chunk)
|
||||||
if _, err := fmt.Fprintf(writer, "data: %s\n\n", encoded); err != nil {
|
if _, err := fmt.Fprintf(writer, "data: %s\n\n", encoded); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -850,7 +852,7 @@ func (s *SoraGatewayService) writeSoraStream(c *gin.Context, model, content stri
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
finalEncoded, _ := json.Marshal(finalChunk)
|
finalEncoded, _ := jsonMarshalRaw(finalChunk)
|
||||||
if _, err := fmt.Fprintf(writer, "data: %s\n\n", finalEncoded); err != nil {
|
if _, err := fmt.Fprintf(writer, "data: %s\n\n", finalEncoded); err != nil {
|
||||||
return &ms, err
|
return &ms, err
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1053,23 @@ func (s *SoraGatewayService) normalizeSoraMediaURLs(urls []string) []string {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonMarshalRaw 序列化 JSON,不转义 &、<、> 等 HTML 字符,
|
||||||
|
// 避免 URL 中的 & 被转义为 \u0026 导致客户端无法直接使用。
|
||||||
|
func jsonMarshalRaw(v any) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Encode 会追加换行符,去掉它
|
||||||
|
b := buf.Bytes()
|
||||||
|
if len(b) > 0 && b[len(b)-1] == '\n' {
|
||||||
|
b = b[:len(b)-1]
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildSoraContent(mediaType string, urls []string) string {
|
func buildSoraContent(mediaType string, urls []string) string {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ func (s *SoraGatewayService) processSoraSSEData(data string, originalModel strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedData, err := json.Marshal(payload)
|
updatedData, err := jsonMarshalRaw(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "data: " + data, contentDelta, nil
|
return "data: " + data, contentDelta, nil
|
||||||
}
|
}
|
||||||
@@ -484,7 +484,7 @@ func (s *SoraGatewayService) flushSoraRewriteBuffer(buffer string, originalModel
|
|||||||
if originalModel != "" {
|
if originalModel != "" {
|
||||||
payload["model"] = originalModel
|
payload["model"] = originalModel
|
||||||
}
|
}
|
||||||
updatedData, err := json.Marshal(payload)
|
updatedData, err := jsonMarshalRaw(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func (s *SoraMediaStorage) downloadAndStore(ctx context.Context, mediaType, rawU
|
|||||||
return relative, nil
|
return relative, nil
|
||||||
}
|
}
|
||||||
if s.debug {
|
if s.debug {
|
||||||
log.Printf("[SoraStorage] 下载失败(%d/%d): %s err=%v", attempt, retries, sanitizeSoraLogURL(rawURL), err)
|
log.Printf("[SoraStorage] 下载失败(%d/%d): %s err=%v", attempt, retries, sanitizeMediaLogURL(rawURL), err)
|
||||||
}
|
}
|
||||||
if attempt < retries {
|
if attempt < retries {
|
||||||
time.Sleep(time.Duration(attempt*attempt) * time.Second)
|
time.Sleep(time.Duration(attempt*attempt) * time.Second)
|
||||||
@@ -252,7 +252,7 @@ func (s *SoraMediaStorage) downloadOnce(ctx context.Context, root, mediaType, ra
|
|||||||
|
|
||||||
relative := path.Join("/", mediaType, datePath, filename)
|
relative := path.Join("/", mediaType, datePath, filename)
|
||||||
if s.debug {
|
if s.debug {
|
||||||
log.Printf("[SoraStorage] 已落地 %s -> %s", sanitizeSoraLogURL(rawURL), relative)
|
log.Printf("[SoraStorage] 已落地 %s -> %s", sanitizeMediaLogURL(rawURL), relative)
|
||||||
}
|
}
|
||||||
return relative, nil
|
return relative, nil
|
||||||
}
|
}
|
||||||
@@ -305,3 +305,19 @@ func removePartialDownload(root *os.Root, filePath string) {
|
|||||||
}
|
}
|
||||||
_ = root.Remove(filePath)
|
_ = root.Remove(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeMediaLogURL 脱敏 URL 用于日志记录(去除 query 参数中可能的 token 信息)
|
||||||
|
func sanitizeMediaLogURL(rawURL string) string {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
if len(rawURL) > 80 {
|
||||||
|
return rawURL[:80] + "..."
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
safe := parsed.Scheme + "://" + parsed.Host + parsed.Path
|
||||||
|
if len(safe) > 120 {
|
||||||
|
return safe[:120] + "..."
|
||||||
|
}
|
||||||
|
return safe
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type soraChallengeCooldownEntry struct {
|
|
||||||
Until time.Time
|
|
||||||
StatusCode int
|
|
||||||
CFRay string
|
|
||||||
ConsecutiveChallenges int
|
|
||||||
LastChallengeAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type soraSidecarSessionEntry struct {
|
|
||||||
SessionKey string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
LastUsedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) cloudflareChallengeCooldownSeconds() int {
|
|
||||||
if c == nil || c.cfg == nil {
|
|
||||||
return 900
|
|
||||||
}
|
|
||||||
cooldown := c.cfg.Sora.Client.CloudflareChallengeCooldownSeconds
|
|
||||||
if cooldown <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return cooldown
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) checkCloudflareChallengeCooldown(account *Account, proxyURL string) error {
|
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if account == nil || account.ID <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cooldownSeconds := c.cloudflareChallengeCooldownSeconds()
|
|
||||||
if cooldownSeconds <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key := soraAccountProxyKey(account, proxyURL)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
c.challengeCooldownMu.RLock()
|
|
||||||
entry, ok := c.challengeCooldowns[key]
|
|
||||||
c.challengeCooldownMu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !entry.Until.After(now) {
|
|
||||||
c.challengeCooldownMu.Lock()
|
|
||||||
delete(c.challengeCooldowns, key)
|
|
||||||
c.challengeCooldownMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := int(math.Ceil(entry.Until.Sub(now).Seconds()))
|
|
||||||
if remaining < 1 {
|
|
||||||
remaining = 1
|
|
||||||
}
|
|
||||||
message := fmt.Sprintf("Sora request cooling down due to recent Cloudflare challenge. Retry in %d seconds.", remaining)
|
|
||||||
if entry.ConsecutiveChallenges > 1 {
|
|
||||||
message = fmt.Sprintf("%s (streak=%d)", message, entry.ConsecutiveChallenges)
|
|
||||||
}
|
|
||||||
if entry.CFRay != "" {
|
|
||||||
message = fmt.Sprintf("%s (last cf-ray: %s)", message, entry.CFRay)
|
|
||||||
}
|
|
||||||
return &SoraUpstreamError{
|
|
||||||
StatusCode: http.StatusTooManyRequests,
|
|
||||||
Message: message,
|
|
||||||
Headers: make(http.Header),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) recordCloudflareChallengeCooldown(account *Account, proxyURL string, statusCode int, headers http.Header, body []byte) {
|
|
||||||
if c == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account == nil || account.ID <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cooldownSeconds := c.cloudflareChallengeCooldownSeconds()
|
|
||||||
if cooldownSeconds <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := soraAccountProxyKey(account, proxyURL)
|
|
||||||
now := time.Now()
|
|
||||||
cfRay := soraerror.ExtractCloudflareRayID(headers, body)
|
|
||||||
|
|
||||||
c.challengeCooldownMu.Lock()
|
|
||||||
c.cleanupExpiredChallengeCooldownsLocked(now)
|
|
||||||
|
|
||||||
streak := 1
|
|
||||||
existing, ok := c.challengeCooldowns[key]
|
|
||||||
if ok && now.Sub(existing.LastChallengeAt) <= 30*time.Minute {
|
|
||||||
streak = existing.ConsecutiveChallenges + 1
|
|
||||||
}
|
|
||||||
effectiveCooldown := soraComputeChallengeCooldownSeconds(cooldownSeconds, streak)
|
|
||||||
until := now.Add(time.Duration(effectiveCooldown) * time.Second)
|
|
||||||
if ok && existing.Until.After(until) {
|
|
||||||
until = existing.Until
|
|
||||||
if existing.ConsecutiveChallenges > streak {
|
|
||||||
streak = existing.ConsecutiveChallenges
|
|
||||||
}
|
|
||||||
if cfRay == "" {
|
|
||||||
cfRay = existing.CFRay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.challengeCooldowns[key] = soraChallengeCooldownEntry{
|
|
||||||
Until: until,
|
|
||||||
StatusCode: statusCode,
|
|
||||||
CFRay: cfRay,
|
|
||||||
ConsecutiveChallenges: streak,
|
|
||||||
LastChallengeAt: now,
|
|
||||||
}
|
|
||||||
c.challengeCooldownMu.Unlock()
|
|
||||||
|
|
||||||
if c.debugEnabled() {
|
|
||||||
remain := int(math.Ceil(until.Sub(now).Seconds()))
|
|
||||||
if remain < 0 {
|
|
||||||
remain = 0
|
|
||||||
}
|
|
||||||
c.debugLogf("cloudflare_challenge_cooldown_set key=%s status=%d remain_s=%d streak=%d cf_ray=%s", key, statusCode, remain, streak, cfRay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func soraComputeChallengeCooldownSeconds(baseSeconds, streak int) int {
|
|
||||||
if baseSeconds <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if streak < 1 {
|
|
||||||
streak = 1
|
|
||||||
}
|
|
||||||
multiplier := streak
|
|
||||||
if multiplier > 4 {
|
|
||||||
multiplier = 4
|
|
||||||
}
|
|
||||||
cooldown := baseSeconds * multiplier
|
|
||||||
if cooldown > 3600 {
|
|
||||||
cooldown = 3600
|
|
||||||
}
|
|
||||||
return cooldown
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) clearCloudflareChallengeCooldown(account *Account, proxyURL string) {
|
|
||||||
if c == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account == nil || account.ID <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := soraAccountProxyKey(account, proxyURL)
|
|
||||||
c.challengeCooldownMu.Lock()
|
|
||||||
_, existed := c.challengeCooldowns[key]
|
|
||||||
if existed {
|
|
||||||
delete(c.challengeCooldowns, key)
|
|
||||||
}
|
|
||||||
c.challengeCooldownMu.Unlock()
|
|
||||||
if existed && c.debugEnabled() {
|
|
||||||
c.debugLogf("cloudflare_challenge_cooldown_cleared key=%s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) sidecarSessionKey(account *Account, proxyURL string) string {
|
|
||||||
if c == nil || !c.sidecarSessionReuseEnabled() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if account == nil || account.ID <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
key := soraAccountProxyKey(account, proxyURL)
|
|
||||||
now := time.Now()
|
|
||||||
ttlSeconds := c.sidecarSessionTTLSeconds()
|
|
||||||
|
|
||||||
c.sidecarSessionMu.Lock()
|
|
||||||
defer c.sidecarSessionMu.Unlock()
|
|
||||||
c.cleanupExpiredSidecarSessionsLocked(now)
|
|
||||||
if existing, exists := c.sidecarSessions[key]; exists {
|
|
||||||
existing.LastUsedAt = now
|
|
||||||
c.sidecarSessions[key] = existing
|
|
||||||
return existing.SessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := now.Add(time.Duration(ttlSeconds) * time.Second)
|
|
||||||
if ttlSeconds <= 0 {
|
|
||||||
expiresAt = now.Add(365 * 24 * time.Hour)
|
|
||||||
}
|
|
||||||
newEntry := soraSidecarSessionEntry{
|
|
||||||
SessionKey: "sora-" + uuid.NewString(),
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
LastUsedAt: now,
|
|
||||||
}
|
|
||||||
c.sidecarSessions[key] = newEntry
|
|
||||||
|
|
||||||
if c.debugEnabled() {
|
|
||||||
c.debugLogf("sidecar_session_created key=%s ttl_s=%d", key, ttlSeconds)
|
|
||||||
}
|
|
||||||
return newEntry.SessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) cleanupExpiredChallengeCooldownsLocked(now time.Time) {
|
|
||||||
if c == nil || len(c.challengeCooldowns) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for key, entry := range c.challengeCooldowns {
|
|
||||||
if !entry.Until.After(now) {
|
|
||||||
delete(c.challengeCooldowns, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SoraDirectClient) cleanupExpiredSidecarSessionsLocked(now time.Time) {
|
|
||||||
if c == nil || len(c.sidecarSessions) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for key, entry := range c.sidecarSessions {
|
|
||||||
if !entry.ExpiresAt.After(now) {
|
|
||||||
delete(c.sidecarSessions, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func soraAccountProxyKey(account *Account, proxyURL string) string {
|
|
||||||
accountID := int64(0)
|
|
||||||
if account != nil {
|
|
||||||
accountID = account.ID
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("account:%d|proxy:%s", accountID, normalizeSoraProxyKey(proxyURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeSoraProxyKey(proxyURL string) string {
|
|
||||||
raw := strings.TrimSpace(proxyURL)
|
|
||||||
if raw == "" {
|
|
||||||
return "direct"
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
return strings.ToLower(raw)
|
|
||||||
}
|
|
||||||
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
|
|
||||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
|
||||||
port := strings.TrimSpace(parsed.Port())
|
|
||||||
if host == "" {
|
|
||||||
return strings.ToLower(raw)
|
|
||||||
}
|
|
||||||
if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
|
|
||||||
port = ""
|
|
||||||
}
|
|
||||||
if port != "" {
|
|
||||||
host = host + ":" + port
|
|
||||||
}
|
|
||||||
if scheme == "" {
|
|
||||||
scheme = "proxy"
|
|
||||||
}
|
|
||||||
return scheme + "://" + host
|
|
||||||
}
|
|
||||||
808
backend/internal/service/sora_sdk_client.go
Normal file
808
backend/internal/service/sora_sdk_client.go
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DouDOU-start/go-sora2api/sora"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
openaioauth "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SoraSDKClient 基于 go-sora2api SDK 的 Sora 客户端实现。
|
||||||
|
// 它实现了 SoraClient 接口,用 SDK 替代原有的自建 HTTP/PoW/TLS 指纹逻辑。
|
||||||
|
type SoraSDKClient struct {
|
||||||
|
cfg *config.Config
|
||||||
|
httpUpstream HTTPUpstream
|
||||||
|
tokenProvider *OpenAITokenProvider
|
||||||
|
accountRepo AccountRepository
|
||||||
|
soraAccountRepo SoraAccountRepository
|
||||||
|
|
||||||
|
// 每个 proxyURL 对应一个 SDK 客户端实例
|
||||||
|
sdkClients sync.Map // key: proxyURL (string), value: *sora.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSoraSDKClient 创建基于 SDK 的 Sora 客户端
|
||||||
|
func NewSoraSDKClient(cfg *config.Config, httpUpstream HTTPUpstream, tokenProvider *OpenAITokenProvider) *SoraSDKClient {
|
||||||
|
return &SoraSDKClient{
|
||||||
|
cfg: cfg,
|
||||||
|
httpUpstream: httpUpstream,
|
||||||
|
tokenProvider: tokenProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAccountRepositories 设置账号和 Sora 扩展仓库(用于 token 持久化)
|
||||||
|
func (c *SoraSDKClient) SetAccountRepositories(accountRepo AccountRepository, soraAccountRepo SoraAccountRepository) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.accountRepo = accountRepo
|
||||||
|
c.soraAccountRepo = soraAccountRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 判断是否启用 Sora
|
||||||
|
func (c *SoraSDKClient) Enabled() bool {
|
||||||
|
if c == nil || c.cfg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.cfg.Sora.Client.BaseURL) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreflightCheck 在创建任务前执行账号能力预检。
|
||||||
|
// 当前仅对视频模型执行预检,用于提前识别额度耗尽或能力缺失。
|
||||||
|
func (c *SoraSDKClient) PreflightCheck(ctx context.Context, account *Account, requestedModel string, modelCfg SoraModelConfig) error {
|
||||||
|
if modelCfg.Type != "video" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
balance, err := sdkClient.GetCreditBalance(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return &SoraUpstreamError{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Message: "当前账号未开通 Sora2 能力或无可用配额",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if balance.RateLimitReached || balance.RemainingCount <= 0 {
|
||||||
|
msg := "当前账号 Sora2 可用配额不足"
|
||||||
|
if requestedModel != "" {
|
||||||
|
msg = fmt.Sprintf("当前账号 %s 可用配额不足", requestedModel)
|
||||||
|
}
|
||||||
|
return &SoraUpstreamError{
|
||||||
|
StatusCode: http.StatusTooManyRequests,
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) UploadImage(ctx context.Context, account *Account, data []byte, filename string) (string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", errors.New("empty image data")
|
||||||
|
}
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = "image.png"
|
||||||
|
}
|
||||||
|
mediaID, err := sdkClient.UploadImage(ctx, token, data, filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return mediaID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) CreateImageTask(ctx context.Context, account *Account, req SoraImageRequest) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sentinel, err := sdkClient.GenerateSentinelToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
var taskID string
|
||||||
|
if strings.TrimSpace(req.MediaID) != "" {
|
||||||
|
taskID, err = sdkClient.CreateImageTaskWithImage(ctx, token, sentinel, req.Prompt, req.Width, req.Height, req.MediaID)
|
||||||
|
} else {
|
||||||
|
taskID, err = sdkClient.CreateImageTask(ctx, token, sentinel, req.Prompt, req.Width, req.Height)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return taskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) CreateVideoTask(ctx context.Context, account *Account, req SoraVideoRequest) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sentinel, err := sdkClient.GenerateSentinelToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
orientation := req.Orientation
|
||||||
|
if orientation == "" {
|
||||||
|
orientation = "landscape"
|
||||||
|
}
|
||||||
|
nFrames := req.Frames
|
||||||
|
if nFrames <= 0 {
|
||||||
|
nFrames = 450
|
||||||
|
}
|
||||||
|
model := req.Model
|
||||||
|
if model == "" {
|
||||||
|
model = "sy_8"
|
||||||
|
}
|
||||||
|
size := req.Size
|
||||||
|
if size == "" {
|
||||||
|
size = "small"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remix 模式
|
||||||
|
if strings.TrimSpace(req.RemixTargetID) != "" {
|
||||||
|
styleID := "" // SDK ExtractStyle 可从 prompt 中提取
|
||||||
|
taskID, err := sdkClient.RemixVideo(ctx, token, sentinel, req.RemixTargetID, req.Prompt, orientation, nFrames, styleID)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return taskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通视频(文生视频或图生视频)
|
||||||
|
taskID, err := sdkClient.CreateVideoTaskWithOptions(ctx, token, sentinel, req.Prompt, orientation, nFrames, model, size, req.MediaID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return taskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) CreateStoryboardTask(ctx context.Context, account *Account, req SoraStoryboardRequest) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sentinel, err := sdkClient.GenerateSentinelToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
orientation := req.Orientation
|
||||||
|
if orientation == "" {
|
||||||
|
orientation = "landscape"
|
||||||
|
}
|
||||||
|
nFrames := req.Frames
|
||||||
|
if nFrames <= 0 {
|
||||||
|
nFrames = 450
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := sdkClient.CreateStoryboardTask(ctx, token, sentinel, req.Prompt, orientation, nFrames, req.MediaID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return taskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) UploadCharacterVideo(ctx context.Context, account *Account, data []byte) (string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", errors.New("empty video data")
|
||||||
|
}
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cameoID, err := sdkClient.UploadCharacterVideo(ctx, token, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return cameoID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) GetCameoStatus(ctx context.Context, account *Account, cameoID string) (*SoraCameoStatus, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, err := sdkClient.GetCameoStatus(ctx, token, cameoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return &SoraCameoStatus{
|
||||||
|
Status: status.Status,
|
||||||
|
DisplayNameHint: status.DisplayNameHint,
|
||||||
|
UsernameHint: status.UsernameHint,
|
||||||
|
ProfileAssetURL: status.ProfileAssetURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error) {
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := sdkClient.DownloadCharacterImage(ctx, imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) UploadCharacterImage(ctx context.Context, account *Account, data []byte) (string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", errors.New("empty character image")
|
||||||
|
}
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assetPointer, err := sdkClient.UploadCharacterImage(ctx, token, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return assetPointer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) FinalizeCharacter(ctx context.Context, account *Account, req SoraCharacterFinalizeRequest) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
characterID, err := sdkClient.FinalizeCharacter(ctx, token, req.CameoID, req.Username, req.DisplayName, req.ProfileAssetPointer)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return characterID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) SetCharacterPublic(ctx context.Context, account *Account, cameoID string) error {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sdkClient.SetCharacterPublic(ctx, token, cameoID); err != nil {
|
||||||
|
return c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) DeleteCharacter(ctx context.Context, account *Account, characterID string) error {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sdkClient.DeleteCharacter(ctx, token, characterID); err != nil {
|
||||||
|
return c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) PostVideoForWatermarkFree(ctx context.Context, account *Account, generationID string) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sentinel, err := sdkClient.GenerateSentinelToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
postID, err := sdkClient.PublishVideo(ctx, token, sentinel, generationID)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return postID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) DeletePost(ctx context.Context, account *Account, postID string) error {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sdkClient.DeletePost(ctx, token, postID); err != nil {
|
||||||
|
return c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWatermarkFreeURLCustom 使用自定义第三方解析服务获取去水印链接。
|
||||||
|
// SDK 不涉及此功能,保留自建实现。
|
||||||
|
func (c *SoraSDKClient) GetWatermarkFreeURLCustom(ctx context.Context, account *Account, parseURL, parseToken, postID string) (string, error) {
|
||||||
|
parseURL = strings.TrimRight(strings.TrimSpace(parseURL), "/")
|
||||||
|
if parseURL == "" {
|
||||||
|
return "", errors.New("custom parse url is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(parseToken) == "" {
|
||||||
|
return "", errors.New("custom parse token is required")
|
||||||
|
}
|
||||||
|
shareURL := "https://sora.chatgpt.com/p/" + strings.TrimSpace(postID)
|
||||||
|
payload := map[string]any{
|
||||||
|
"url": shareURL,
|
||||||
|
"token": strings.TrimSpace(parseToken),
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parseURL+"/get-sora-link", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
proxyURL := c.resolveProxyURL(account)
|
||||||
|
accountID := int64(0)
|
||||||
|
accountConcurrency := 0
|
||||||
|
if account != nil {
|
||||||
|
accountID = account.ID
|
||||||
|
accountConcurrency = account.Concurrency
|
||||||
|
}
|
||||||
|
var resp *http.Response
|
||||||
|
if c.httpUpstream != nil {
|
||||||
|
resp, err = c.httpUpstream.Do(req, proxyURL, accountID, accountConcurrency)
|
||||||
|
} else {
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("custom parse failed: %d %s", resp.StatusCode, truncateForLog(raw, 256))
|
||||||
|
}
|
||||||
|
downloadLink := strings.TrimSpace(gjson.GetBytes(raw, "download_link").String())
|
||||||
|
if downloadLink == "" {
|
||||||
|
return "", errors.New("custom parse response missing download_link")
|
||||||
|
}
|
||||||
|
return downloadLink, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) EnhancePrompt(ctx context.Context, account *Account, prompt, expansionLevel string, durationS int) (string, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(expansionLevel) == "" {
|
||||||
|
expansionLevel = "medium"
|
||||||
|
}
|
||||||
|
if durationS <= 0 {
|
||||||
|
durationS = 10
|
||||||
|
}
|
||||||
|
enhanced, err := sdkClient.EnhancePrompt(ctx, token, prompt, expansionLevel, durationS)
|
||||||
|
if err != nil {
|
||||||
|
return "", c.wrapSDKError(err, account)
|
||||||
|
}
|
||||||
|
return enhanced, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) GetImageTask(ctx context.Context, account *Account, taskID string) (*SoraImageTaskStatus, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := sdkClient.QueryImageTaskOnce(ctx, token, taskID, time.Now().Add(-10*time.Second))
|
||||||
|
if result.Err != nil {
|
||||||
|
return &SoraImageTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorMsg: result.Err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if result.Done && result.ImageURL != "" {
|
||||||
|
return &SoraImageTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "succeeded",
|
||||||
|
URLs: []string{result.ImageURL},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
status := result.Progress.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "processing"
|
||||||
|
}
|
||||||
|
return &SoraImageTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: status,
|
||||||
|
ProgressPct: float64(result.Progress.Percent) / 100.0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) GetVideoTask(ctx context.Context, account *Account, taskID string) (*SoraVideoTaskStatus, error) {
|
||||||
|
token, err := c.getAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查询 pending 列表
|
||||||
|
result := sdkClient.QueryVideoTaskOnce(ctx, token, taskID, time.Now().Add(-10*time.Second), 0)
|
||||||
|
if result.Err != nil {
|
||||||
|
return &SoraVideoTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorMsg: result.Err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if !result.Done {
|
||||||
|
return &SoraVideoTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: result.Progress.Status,
|
||||||
|
ProgressPct: result.Progress.Percent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务不在 pending 中,查询 drafts 获取下载链接
|
||||||
|
downloadURL, err := sdkClient.GetDownloadURL(ctx, token, taskID)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if strings.Contains(errMsg, "内容违规") || strings.Contains(errMsg, "Content violates") {
|
||||||
|
return &SoraVideoTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "failed",
|
||||||
|
ErrorMsg: errMsg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// 可能还在处理中
|
||||||
|
return &SoraVideoTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "processing",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &SoraVideoTaskStatus{
|
||||||
|
ID: taskID,
|
||||||
|
Status: "completed",
|
||||||
|
URLs: []string{downloadURL},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内部方法 ---
|
||||||
|
|
||||||
|
// getSDKClient 获取或创建指定代理的 SDK 客户端实例
|
||||||
|
func (c *SoraSDKClient) getSDKClient(account *Account) (*sora.Client, error) {
|
||||||
|
proxyURL := c.resolveProxyURL(account)
|
||||||
|
if v, ok := c.sdkClients.Load(proxyURL); ok {
|
||||||
|
if cli, ok2 := v.(*sora.Client); ok2 {
|
||||||
|
return cli, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client, err := sora.New(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建 Sora SDK 客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
actual, _ := c.sdkClients.LoadOrStore(proxyURL, client)
|
||||||
|
if cli, ok := actual.(*sora.Client); ok {
|
||||||
|
return cli, nil
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) resolveProxyURL(account *Account) string {
|
||||||
|
if account == nil || account.ProxyID == nil || account.Proxy == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(account.Proxy.URL())
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken 获取账号的 access_token,支持多种 token 来源和自动刷新。
|
||||||
|
// 此方法保留了原 SoraDirectClient 的 token 管理逻辑。
|
||||||
|
func (c *SoraSDKClient) getAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||||
|
if account == nil {
|
||||||
|
return "", errors.New("account is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先尝试 OpenAI Token Provider
|
||||||
|
allowProvider := c.allowOpenAITokenProvider(account)
|
||||||
|
var providerErr error
|
||||||
|
if allowProvider && c.tokenProvider != nil {
|
||||||
|
token, err := c.tokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
c.debugLogf("token_selected account_id=%d source=openai_token_provider", account.ID)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
providerErr = err
|
||||||
|
if err != nil && c.debugEnabled() {
|
||||||
|
c.debugLogf("token_provider_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试直接使用 credentials 中的 access_token
|
||||||
|
token := strings.TrimSpace(account.GetCredential("access_token"))
|
||||||
|
if token != "" {
|
||||||
|
expiresAt := account.GetCredentialAsTime("expires_at")
|
||||||
|
if expiresAt != nil && time.Until(*expiresAt) <= 2*time.Minute {
|
||||||
|
refreshed, refreshErr := c.recoverAccessToken(ctx, account, "access_token_expiring")
|
||||||
|
if refreshErr == nil && strings.TrimSpace(refreshed) != "" {
|
||||||
|
return refreshed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试通过 session_token 或 refresh_token 恢复
|
||||||
|
recovered, recoverErr := c.recoverAccessToken(ctx, account, "access_token_missing")
|
||||||
|
if recoverErr == nil && strings.TrimSpace(recovered) != "" {
|
||||||
|
return recovered, nil
|
||||||
|
}
|
||||||
|
if providerErr != nil {
|
||||||
|
return "", providerErr
|
||||||
|
}
|
||||||
|
return "", errors.New("access_token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverAccessToken 通过 session_token 或 refresh_token 恢复 access_token
|
||||||
|
func (c *SoraSDKClient) recoverAccessToken(ctx context.Context, account *Account, reason string) (string, error) {
|
||||||
|
if account == nil {
|
||||||
|
return "", errors.New("account is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试 session_token
|
||||||
|
if sessionToken := strings.TrimSpace(account.GetCredential("session_token")); sessionToken != "" {
|
||||||
|
accessToken, expiresAt, err := c.exchangeSessionToken(ctx, account, sessionToken)
|
||||||
|
if err == nil && strings.TrimSpace(accessToken) != "" {
|
||||||
|
c.applyRecoveredToken(ctx, account, accessToken, "", expiresAt, sessionToken)
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再尝试 refresh_token
|
||||||
|
refreshToken := strings.TrimSpace(account.GetCredential("refresh_token"))
|
||||||
|
if refreshToken == "" {
|
||||||
|
return "", errors.New("session_token/refresh_token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
sdkClient, err := c.getSDKClient(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试多个 client_id
|
||||||
|
clientIDs := []string{
|
||||||
|
strings.TrimSpace(account.GetCredential("client_id")),
|
||||||
|
openaioauth.SoraClientID,
|
||||||
|
openaioauth.ClientID,
|
||||||
|
}
|
||||||
|
tried := make(map[string]struct{}, len(clientIDs))
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for _, clientID := range clientIDs {
|
||||||
|
if clientID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := tried[clientID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tried[clientID] = struct{}{}
|
||||||
|
|
||||||
|
newAccess, newRefresh, refreshErr := sdkClient.RefreshAccessToken(ctx, refreshToken, clientID)
|
||||||
|
if refreshErr != nil {
|
||||||
|
lastErr = refreshErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(newAccess) == "" {
|
||||||
|
lastErr = errors.New("refreshed access_token is empty")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.applyRecoveredToken(ctx, account, newAccess, newRefresh, "", "")
|
||||||
|
return newAccess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
return "", errors.New("no available client_id for refresh_token exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeSessionToken 通过 session_token 换取 access_token
|
||||||
|
func (c *SoraSDKClient) exchangeSessionToken(ctx context.Context, account *Account, sessionToken string) (string, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://sora.chatgpt.com/api/auth/session", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Cookie", "__Secure-next-auth.session-token="+sessionToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||||
|
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||||
|
req.Header.Set("User-Agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
||||||
|
|
||||||
|
proxyURL := c.resolveProxyURL(account)
|
||||||
|
accountID := int64(0)
|
||||||
|
accountConcurrency := 0
|
||||||
|
if account != nil {
|
||||||
|
accountID = account.ID
|
||||||
|
accountConcurrency = account.Concurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
if c.httpUpstream != nil {
|
||||||
|
resp, err = c.httpUpstream.Do(req, proxyURL, accountID, accountConcurrency)
|
||||||
|
} else {
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("session exchange failed: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := strings.TrimSpace(gjson.GetBytes(body, "accessToken").String())
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", "", errors.New("session exchange missing accessToken")
|
||||||
|
}
|
||||||
|
expiresAt := strings.TrimSpace(gjson.GetBytes(body, "expires").String())
|
||||||
|
return accessToken, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRecoveredToken 将恢复的 token 写入账号内存和数据库
|
||||||
|
func (c *SoraSDKClient) applyRecoveredToken(ctx context.Context, account *Account, accessToken, refreshToken, expiresAt, sessionToken string) {
|
||||||
|
if account == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if account.Credentials == nil {
|
||||||
|
account.Credentials = make(map[string]any)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(accessToken) != "" {
|
||||||
|
account.Credentials["access_token"] = accessToken
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(refreshToken) != "" {
|
||||||
|
account.Credentials["refresh_token"] = refreshToken
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(expiresAt) != "" {
|
||||||
|
account.Credentials["expires_at"] = expiresAt
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sessionToken) != "" {
|
||||||
|
account.Credentials["session_token"] = sessionToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.accountRepo != nil {
|
||||||
|
if err := c.accountRepo.Update(ctx, account); err != nil && c.debugEnabled() {
|
||||||
|
c.debugLogf("persist_recovered_token_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.updateSoraAccountExtension(ctx, account, accessToken, refreshToken, sessionToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) updateSoraAccountExtension(ctx context.Context, account *Account, accessToken, refreshToken, sessionToken string) {
|
||||||
|
if c == nil || c.soraAccountRepo == nil || account == nil || account.ID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := make(map[string]any)
|
||||||
|
if strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" {
|
||||||
|
updates["access_token"] = accessToken
|
||||||
|
updates["refresh_token"] = refreshToken
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sessionToken) != "" {
|
||||||
|
updates["session_token"] = sessionToken
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.soraAccountRepo.Upsert(ctx, account.ID, updates); err != nil && c.debugEnabled() {
|
||||||
|
c.debugLogf("persist_sora_extension_failed account_id=%d err=%s", account.ID, logredact.RedactText(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) allowOpenAITokenProvider(account *Account) bool {
|
||||||
|
if c == nil || c.tokenProvider == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if account != nil && account.Platform == PlatformSora {
|
||||||
|
return c.cfg != nil && c.cfg.Sora.Client.UseOpenAITokenProvider
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapSDKError 将 SDK 错误包装为 SoraUpstreamError
|
||||||
|
func (c *SoraSDKClient) wrapSDKError(err error, account *Account) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
statusCode := http.StatusBadGateway
|
||||||
|
if strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403") {
|
||||||
|
statusCode = http.StatusUnauthorized
|
||||||
|
} else if strings.Contains(msg, "HTTP 429") {
|
||||||
|
statusCode = http.StatusTooManyRequests
|
||||||
|
} else if strings.Contains(msg, "HTTP 404") {
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
}
|
||||||
|
return &SoraUpstreamError{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) debugEnabled() bool {
|
||||||
|
return c != nil && c.cfg != nil && c.cfg.Sora.Client.Debug
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraSDKClient) debugLogf(format string, args ...any) {
|
||||||
|
if c.debugEnabled() {
|
||||||
|
log.Printf("[SoraSDK] "+format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,14 +206,14 @@ func ProvideSoraMediaStorage(cfg *config.Config) *SoraMediaStorage {
|
|||||||
return NewSoraMediaStorage(cfg)
|
return NewSoraMediaStorage(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideSoraDirectClient(
|
func ProvideSoraSDKClient(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
tokenProvider *OpenAITokenProvider,
|
tokenProvider *OpenAITokenProvider,
|
||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
soraAccountRepo SoraAccountRepository,
|
soraAccountRepo SoraAccountRepository,
|
||||||
) *SoraDirectClient {
|
) *SoraSDKClient {
|
||||||
client := NewSoraDirectClient(cfg, httpUpstream, tokenProvider)
|
client := NewSoraSDKClient(cfg, httpUpstream, tokenProvider)
|
||||||
client.SetAccountRepositories(accountRepo, soraAccountRepo)
|
client.SetAccountRepositories(accountRepo, soraAccountRepo)
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
@@ -306,8 +306,8 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewGatewayService,
|
NewGatewayService,
|
||||||
ProvideSoraMediaStorage,
|
ProvideSoraMediaStorage,
|
||||||
ProvideSoraMediaCleanupService,
|
ProvideSoraMediaCleanupService,
|
||||||
ProvideSoraDirectClient,
|
ProvideSoraSDKClient,
|
||||||
wire.Bind(new(SoraClient), new(*SoraDirectClient)),
|
wire.Bind(new(SoraClient), new(*SoraSDKClient)),
|
||||||
NewSoraGatewayService,
|
NewSoraGatewayService,
|
||||||
NewOpenAIGatewayService,
|
NewOpenAIGatewayService,
|
||||||
NewOAuthService,
|
NewOAuthService,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Add gemini-3.1-flash-image and gemini-3.1-flash-image-preview to model_mapping
|
||||||
|
--
|
||||||
|
-- Background:
|
||||||
|
-- Antigravity now supports gemini-3.1-flash-image as the latest image generation model,
|
||||||
|
-- replacing the previous gemini-3-pro-image.
|
||||||
|
--
|
||||||
|
-- Strategy:
|
||||||
|
-- Directly overwrite the entire model_mapping with updated mappings
|
||||||
|
-- This ensures consistency with DefaultAntigravityModelMapping in constants.go
|
||||||
|
|
||||||
|
UPDATE accounts
|
||||||
|
SET credentials = jsonb_set(
|
||||||
|
credentials,
|
||||||
|
'{model_mapping}',
|
||||||
|
'{
|
||||||
|
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
|
||||||
|
"claude-opus-4-6": "claude-opus-4-6-thinking",
|
||||||
|
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking",
|
||||||
|
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking",
|
||||||
|
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||||
|
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||||
|
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||||
|
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||||
|
"claude-haiku-4-5": "claude-sonnet-4-5",
|
||||||
|
"claude-haiku-4-5-20251001": "claude-sonnet-4-5",
|
||||||
|
"gemini-2.5-flash": "gemini-2.5-flash",
|
||||||
|
"gemini-2.5-flash-lite": "gemini-2.5-flash-lite",
|
||||||
|
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
|
||||||
|
"gemini-2.5-pro": "gemini-2.5-pro",
|
||||||
|
"gemini-3-flash": "gemini-3-flash",
|
||||||
|
"gemini-3-pro-high": "gemini-3-pro-high",
|
||||||
|
"gemini-3-pro-low": "gemini-3-pro-low",
|
||||||
|
"gemini-3-flash-preview": "gemini-3-flash",
|
||||||
|
"gemini-3-pro-preview": "gemini-3-pro-high",
|
||||||
|
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
|
||||||
|
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
|
||||||
|
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
||||||
|
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||||
|
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
|
||||||
|
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
|
||||||
|
"tab_flash_lite_preview": "tab_flash_lite_preview"
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
WHERE platform = 'antigravity'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND credentials->'model_mapping' IS NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -781,10 +781,10 @@ rate_limit:
|
|||||||
pricing:
|
pricing:
|
||||||
# URL to fetch model pricing data (default: LiteLLM)
|
# URL to fetch model pricing data (default: LiteLLM)
|
||||||
# 获取模型定价数据的 URL(默认:LiteLLM)
|
# 获取模型定价数据的 URL(默认:LiteLLM)
|
||||||
remote_url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
remote_url: "https://github.com/Wei-Shaw/model-price-repo/raw/refs/heads/main/model_prices_and_context_window.json"
|
||||||
# Hash verification URL (optional)
|
# Hash verification URL (optional)
|
||||||
# 哈希校验 URL(可选)
|
# 哈希校验 URL(可选)
|
||||||
hash_url: ""
|
hash_url: "https://github.com/Wei-Shaw/model-price-repo/raw/refs/heads/main/model_prices_and_context_window.sha256"
|
||||||
# Local data directory for caching
|
# Local data directory for caching
|
||||||
# 本地数据缓存目录
|
# 本地数据缓存目录
|
||||||
data_dir: "./data"
|
data_dir: "./data"
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ services:
|
|||||||
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
|
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
|
||||||
- PGDATA=/var/lib/postgresql/data
|
|
||||||
- TZ=${TZ:-Asia/Shanghai}
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
networks:
|
networks:
|
||||||
- sub2api-network
|
- sub2api-network
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ const activeModelRateLimits = computed(() => {
|
|||||||
const formatScopeName = (scope: string): string => {
|
const formatScopeName = (scope: string): string => {
|
||||||
const aliases: Record<string, string> = {
|
const aliases: Record<string, string> = {
|
||||||
// Claude 系列
|
// Claude 系列
|
||||||
'claude-opus-4-6-thinking': 'COpus46',
|
'claude-opus-4-6': 'COpus46',
|
||||||
|
'claude-opus-4-6-thinking': 'COpus46T',
|
||||||
'claude-sonnet-4-6': 'CSon46',
|
'claude-sonnet-4-6': 'CSon46',
|
||||||
'claude-sonnet-4-5': 'CSon45',
|
'claude-sonnet-4-5': 'CSon45',
|
||||||
'claude-sonnet-4-5-thinking': 'CSon45T',
|
'claude-sonnet-4-5-thinking': 'CSon45T',
|
||||||
@@ -180,6 +181,7 @@ const formatScopeName = (scope: string): string => {
|
|||||||
'gemini-3.1-pro-high': 'G3PH',
|
'gemini-3.1-pro-high': 'G3PH',
|
||||||
'gemini-3.1-pro-low': 'G3PL',
|
'gemini-3.1-pro-low': 'G3PL',
|
||||||
'gemini-3-pro-image': 'G3PI',
|
'gemini-3-pro-image': 'G3PI',
|
||||||
|
'gemini-3.1-flash-image': 'GImage',
|
||||||
// 其他
|
// 其他
|
||||||
'gpt-oss-120b-medium': 'GPT120',
|
'gpt-oss-120b-medium': 'GPT120',
|
||||||
'tab_flash_lite_preview': 'TabFL',
|
'tab_flash_lite_preview': 'TabFL',
|
||||||
|
|||||||
@@ -397,14 +397,14 @@ const antigravity3ProUsageFromAPI = computed(() =>
|
|||||||
// Gemini 3 Flash from API
|
// Gemini 3 Flash from API
|
||||||
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
|
const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-flash']))
|
||||||
|
|
||||||
// Gemini 3 Image from API
|
// Gemini Image from API
|
||||||
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3-pro-image']))
|
const antigravity3ImageUsageFromAPI = computed(() => getAntigravityUsageFromAPI(['gemini-3.1-flash-image']))
|
||||||
|
|
||||||
// Claude from API (all Claude model variants)
|
// Claude from API (all Claude model variants)
|
||||||
const antigravityClaudeUsageFromAPI = computed(() =>
|
const antigravityClaudeUsageFromAPI = computed(() =>
|
||||||
getAntigravityUsageFromAPI([
|
getAntigravityUsageFromAPI([
|
||||||
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
|
'claude-sonnet-4-5', 'claude-opus-4-5-thinking',
|
||||||
'claude-sonnet-4-6', 'claude-opus-4-6-thinking',
|
'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-6-thinking',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mixed platform warning -->
|
||||||
|
<div v-if="isMixedPlatform" class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.accounts.bulkEdit.mixedPlatformWarning', { platforms: selectedPlatforms.join(', ') }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Base URL (API Key only) -->
|
<!-- Base URL (API Key only) -->
|
||||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
@@ -157,7 +167,7 @@
|
|||||||
<!-- Model Checkbox List -->
|
<!-- Model Checkbox List -->
|
||||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||||
<label
|
<label
|
||||||
v-for="model in allModels"
|
v-for="model in filteredModels"
|
||||||
:key="model.value"
|
:key="model.value"
|
||||||
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||||||
:class="
|
:class="
|
||||||
@@ -278,7 +288,7 @@
|
|||||||
<!-- Quick Add Buttons -->
|
<!-- Quick Add Buttons -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="preset in presetMappings"
|
v-for="preset in filteredPresets"
|
||||||
:key="preset.label"
|
:key="preset.label"
|
||||||
type="button"
|
type="button"
|
||||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||||
@@ -648,7 +658,7 @@ import { ref, watch, computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Proxy, AdminGroup } from '@/types'
|
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
@@ -659,7 +669,8 @@ import { buildModelMappingObject as buildModelMappingPayload } from '@/composabl
|
|||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
accountIds: number[]
|
accountIds: number[]
|
||||||
proxies: Proxy[]
|
selectedPlatforms: AccountPlatform[]
|
||||||
|
proxies: ProxyConfig[]
|
||||||
groups: AdminGroup[]
|
groups: AdminGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +683,31 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Platform awareness
|
||||||
|
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
||||||
|
|
||||||
|
const platformModelPrefix: Record<string, string[]> = {
|
||||||
|
anthropic: ['claude-'],
|
||||||
|
antigravity: ['claude-'],
|
||||||
|
openai: ['gpt-'],
|
||||||
|
gemini: ['gemini-'],
|
||||||
|
sora: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredModels = computed(() => {
|
||||||
|
if (props.selectedPlatforms.length === 0) return allModels
|
||||||
|
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
|
||||||
|
if (prefixes.length === 0) return allModels
|
||||||
|
return allModels.filter(m => prefixes.some(prefix => m.value.startsWith(prefix)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPresets = computed(() => {
|
||||||
|
if (props.selectedPlatforms.length === 0) return presetMappings
|
||||||
|
const prefixes = [...new Set(props.selectedPlatforms.flatMap(p => platformModelPrefix[p] || []))]
|
||||||
|
if (prefixes.length === 0) return presetMappings
|
||||||
|
return presetMappings.filter(m => prefixes.some(prefix => m.from.startsWith(prefix)))
|
||||||
|
})
|
||||||
|
|
||||||
// Model mapping type
|
// Model mapping type
|
||||||
interface ModelMapping {
|
interface ModelMapping {
|
||||||
from: string
|
from: string
|
||||||
@@ -718,6 +754,8 @@ const allModels = [
|
|||||||
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||||
|
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||||
|
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
|
||||||
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
|
||||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||||
@@ -757,7 +795,14 @@ const presetMappings = [
|
|||||||
{
|
{
|
||||||
label: 'Opus 4.6',
|
label: 'Opus 4.6',
|
||||||
from: 'claude-opus-4-6',
|
from: 'claude-opus-4-6',
|
||||||
to: 'claude-opus-4-6',
|
to: 'claude-opus-4-6-thinking',
|
||||||
|
color:
|
||||||
|
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Opus 4.6-thinking',
|
||||||
|
from: 'claude-opus-4-6-thinking',
|
||||||
|
to: 'claude-opus-4-6-thinking',
|
||||||
color:
|
color:
|
||||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
},
|
},
|
||||||
@@ -799,6 +844,24 @@ const presetMappings = [
|
|||||||
to: 'claude-sonnet-4-5-20250929',
|
to: 'claude-sonnet-4-5-20250929',
|
||||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT-5.3 Codex',
|
||||||
|
from: 'gpt-5.3-codex',
|
||||||
|
to: 'gpt-5.3-codex',
|
||||||
|
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GPT-5.3 Spark',
|
||||||
|
from: 'gpt-5.3-codex-spark',
|
||||||
|
to: 'gpt-5.3-codex-spark',
|
||||||
|
color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '5.2→5.3',
|
||||||
|
from: 'gpt-5.2-codex',
|
||||||
|
to: 'gpt-5.3-codex',
|
||||||
|
color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'GPT-5.2',
|
label: 'GPT-5.2',
|
||||||
from: 'gpt-5.2-2025-12-11',
|
from: 'gpt-5.2-2025-12-11',
|
||||||
|
|||||||
@@ -1816,12 +1816,14 @@
|
|||||||
:show-cookie-option="form.platform === 'anthropic'"
|
:show-cookie-option="form.platform === 'anthropic'"
|
||||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
|
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
|
||||||
:show-session-token-option="form.platform === 'sora'"
|
:show-session-token-option="form.platform === 'sora'"
|
||||||
|
:show-access-token-option="form.platform === 'sora'"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||||
@generate-url="handleGenerateUrl"
|
@generate-url="handleGenerateUrl"
|
||||||
@cookie-auth="handleCookieAuth"
|
@cookie-auth="handleCookieAuth"
|
||||||
@validate-refresh-token="handleValidateRefreshToken"
|
@validate-refresh-token="handleValidateRefreshToken"
|
||||||
@validate-session-token="handleValidateSessionToken"
|
@validate-session-token="handleValidateSessionToken"
|
||||||
|
@import-access-token="handleImportAccessToken"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -3188,6 +3190,83 @@ const handleValidateSessionToken = (sessionToken: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sora 手动 AT 批量导入
|
||||||
|
const handleImportAccessToken = async (accessTokenInput: string) => {
|
||||||
|
const oauthClient = activeOpenAIOAuth.value
|
||||||
|
if (!accessTokenInput.trim()) return
|
||||||
|
|
||||||
|
const accessTokens = accessTokenInput
|
||||||
|
.split('\n')
|
||||||
|
.map((at) => at.trim())
|
||||||
|
.filter((at) => at)
|
||||||
|
|
||||||
|
if (accessTokens.length === 0) {
|
||||||
|
oauthClient.error.value = 'Please enter at least one Access Token'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient.loading.value = true
|
||||||
|
oauthClient.error.value = ''
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < accessTokens.length; i++) {
|
||||||
|
try {
|
||||||
|
const credentials: Record<string, unknown> = {
|
||||||
|
access_token: accessTokens[i],
|
||||||
|
}
|
||||||
|
const soraExtra = buildSoraExtra()
|
||||||
|
|
||||||
|
const accountName = accessTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
await adminAPI.accounts.create({
|
||||||
|
name: accountName,
|
||||||
|
notes: form.notes,
|
||||||
|
platform: 'sora',
|
||||||
|
type: 'oauth',
|
||||||
|
credentials,
|
||||||
|
extra: soraExtra,
|
||||||
|
proxy_id: form.proxy_id,
|
||||||
|
concurrency: form.concurrency,
|
||||||
|
priority: form.priority,
|
||||||
|
rate_multiplier: form.rate_multiplier,
|
||||||
|
group_ids: form.group_ids,
|
||||||
|
expires_at: form.expires_at,
|
||||||
|
auto_pause_on_expired: autoPauseOnExpired.value
|
||||||
|
})
|
||||||
|
successCount++
|
||||||
|
} catch (error: any) {
|
||||||
|
failedCount++
|
||||||
|
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
|
||||||
|
errors.push(`#${i + 1}: ${errMsg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0 && failedCount === 0) {
|
||||||
|
appStore.showSuccess(
|
||||||
|
accessTokens.length > 1
|
||||||
|
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||||
|
: t('admin.accounts.accountCreated')
|
||||||
|
)
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
} else if (successCount > 0 && failedCount > 0) {
|
||||||
|
appStore.showWarning(
|
||||||
|
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||||
|
)
|
||||||
|
oauthClient.error.value = errors.join('\n')
|
||||||
|
emit('created')
|
||||||
|
} else {
|
||||||
|
oauthClient.error.value = errors.join('\n')
|
||||||
|
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
oauthClient.loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDateTimeLocal = formatDateTimeLocalInput
|
const formatDateTimeLocal = formatDateTimeLocalInput
|
||||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
const parseDateTimeLocal = parseDateTimeLocalInput
|
||||||
|
|
||||||
|
|||||||
@@ -664,6 +664,7 @@
|
|||||||
class="input"
|
class="input"
|
||||||
data-tour="account-form-priority"
|
data-tour="account-form-priority"
|
||||||
/>
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||||
|
|||||||
@@ -59,6 +59,17 @@
|
|||||||
t(getOAuthKey('sessionTokenAuth'))
|
t(getOAuthKey('sessionTokenAuth'))
|
||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="showAccessTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="inputMethod"
|
||||||
|
type="radio"
|
||||||
|
value="access_token"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||||
|
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,6 +238,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Token Input (Sora) -->
|
||||||
|
<div v-if="inputMethod === 'access_token'" class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{{ t('admin.accounts.oauth.openai.accessTokenDesc', '直接粘贴 Access Token 创建账号,无需 OAuth 授权流程。支持批量导入(每行一个)。') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<Icon name="key" size="sm" class="text-blue-500" />
|
||||||
|
Access Token
|
||||||
|
<span
|
||||||
|
v-if="parsedAccessTokenCount > 1"
|
||||||
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.oauth.keysCount', { count: parsedAccessTokenCount }) }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="accessTokenInput"
|
||||||
|
rows="3"
|
||||||
|
class="input w-full resize-y font-mono text-sm"
|
||||||
|
:placeholder="t('admin.accounts.oauth.openai.accessTokenPlaceholder', '粘贴 Access Token,每行一个')"
|
||||||
|
></textarea>
|
||||||
|
<p
|
||||||
|
v-if="parsedAccessTokenCount > 1"
|
||||||
|
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedAccessTokenCount }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="loading || !accessTokenInput.trim()"
|
||||||
|
@click="handleImportAccessToken"
|
||||||
|
>
|
||||||
|
<Icon name="sparkles" size="sm" class="mr-2" />
|
||||||
|
{{ t('admin.accounts.oauth.openai.importAccessToken', '导入 Access Token') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cookie Auto-Auth Form -->
|
<!-- Cookie Auto-Auth Form -->
|
||||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
@@ -618,6 +686,7 @@ interface Props {
|
|||||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||||
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
|
||||||
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
|
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
|
||||||
|
showAccessTokenOption?: boolean // Whether to show access token input option (Sora only)
|
||||||
platform?: AccountPlatform // Platform type for different UI/text
|
platform?: AccountPlatform // Platform type for different UI/text
|
||||||
showProjectId?: boolean // New prop to control project ID visibility
|
showProjectId?: boolean // New prop to control project ID visibility
|
||||||
}
|
}
|
||||||
@@ -634,6 +703,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
showCookieOption: true,
|
showCookieOption: true,
|
||||||
showRefreshTokenOption: false,
|
showRefreshTokenOption: false,
|
||||||
showSessionTokenOption: false,
|
showSessionTokenOption: false,
|
||||||
|
showAccessTokenOption: false,
|
||||||
platform: 'anthropic',
|
platform: 'anthropic',
|
||||||
showProjectId: true
|
showProjectId: true
|
||||||
})
|
})
|
||||||
@@ -644,6 +714,7 @@ const emit = defineEmits<{
|
|||||||
'cookie-auth': [sessionKey: string]
|
'cookie-auth': [sessionKey: string]
|
||||||
'validate-refresh-token': [refreshToken: string]
|
'validate-refresh-token': [refreshToken: string]
|
||||||
'validate-session-token': [sessionToken: string]
|
'validate-session-token': [sessionToken: string]
|
||||||
|
'import-access-token': [accessToken: string]
|
||||||
'update:inputMethod': [method: AuthInputMethod]
|
'update:inputMethod': [method: AuthInputMethod]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -683,12 +754,13 @@ const authCodeInput = ref('')
|
|||||||
const sessionKeyInput = ref('')
|
const sessionKeyInput = ref('')
|
||||||
const refreshTokenInput = ref('')
|
const refreshTokenInput = ref('')
|
||||||
const sessionTokenInput = ref('')
|
const sessionTokenInput = ref('')
|
||||||
|
const accessTokenInput = ref('')
|
||||||
const showHelpDialog = ref(false)
|
const showHelpDialog = ref(false)
|
||||||
const oauthState = ref('')
|
const oauthState = ref('')
|
||||||
const projectId = ref('')
|
const projectId = ref('')
|
||||||
|
|
||||||
// Computed: show method selection when either cookie or refresh token option is enabled
|
// Computed: show method selection when either cookie or refresh token option is enabled
|
||||||
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption)
|
const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
const { copied, copyToClipboard } = useClipboard()
|
const { copied, copyToClipboard } = useClipboard()
|
||||||
@@ -716,6 +788,13 @@ const parsedSessionTokenCount = computed(() => {
|
|||||||
.filter((st) => st).length
|
.filter((st) => st).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parsedAccessTokenCount = computed(() => {
|
||||||
|
return accessTokenInput.value
|
||||||
|
.split('\n')
|
||||||
|
.map((at) => at.trim())
|
||||||
|
.filter((at) => at).length
|
||||||
|
})
|
||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(inputMethod, (newVal) => {
|
watch(inputMethod, (newVal) => {
|
||||||
emit('update:inputMethod', newVal)
|
emit('update:inputMethod', newVal)
|
||||||
@@ -789,6 +868,12 @@ const handleValidateSessionToken = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleImportAccessToken = () => {
|
||||||
|
if (accessTokenInput.value.trim()) {
|
||||||
|
emit('import-access-token', accessTokenInput.value.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expose methods and state
|
// Expose methods and state
|
||||||
defineExpose({
|
defineExpose({
|
||||||
authCode: authCodeInput,
|
authCode: authCodeInput,
|
||||||
|
|||||||
@@ -160,6 +160,7 @@
|
|||||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||||
{{ t('common.reset') }}
|
{{ t('common.reset') }}
|
||||||
</button>
|
</button>
|
||||||
|
<slot name="after-reset" />
|
||||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||||
{{ t('admin.usage.cleanup.button') }}
|
{{ t('admin.usage.cleanup.button') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<DataTable :columns="cols" :data="data" :loading="loading">
|
<DataTable :columns="columns" :data="data" :loading="loading">
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-user_agent="{ row }">
|
<template #cell-user_agent="{ row }">
|
||||||
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] whitespace-normal break-all" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
|
||||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
@@ -276,7 +276,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { AdminUsageLog } from '@/types'
|
import type { AdminUsageLog } from '@/types'
|
||||||
|
|
||||||
defineProps(['data', 'loading'])
|
defineProps(['data', 'loading', 'columns'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Tooltip state - cost
|
// Tooltip state - cost
|
||||||
@@ -289,23 +289,6 @@ const tokenTooltipVisible = ref(false)
|
|||||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||||
|
|
||||||
const cols = computed(() => [
|
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
|
||||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
|
||||||
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
|
||||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
|
||||||
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
|
||||||
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
|
||||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
|
||||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
|
||||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
|
||||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
|
||||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
|
||||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
|
||||||
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
|
||||||
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
|
||||||
])
|
|
||||||
|
|
||||||
const formatCacheTokens = (tokens: number): string => {
|
const formatCacheTokens = (tokens: number): string => {
|
||||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||||
|
|||||||
@@ -534,8 +534,104 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const openaiModels = {
|
const openaiModels = {
|
||||||
|
'gpt-5-codex': {
|
||||||
|
name: 'GPT-5 Codex',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gpt-5.1-codex': {
|
||||||
|
name: 'GPT-5.1 Codex',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gpt-5.1-codex-max': {
|
||||||
|
name: 'GPT-5.1 Codex Max',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gpt-5.1-codex-mini': {
|
||||||
|
name: 'GPT-5.1 Codex Mini',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gpt-5.2': {
|
||||||
|
name: 'GPT-5.2',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {},
|
||||||
|
xhigh: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
'gpt-5.3-codex-spark': {
|
'gpt-5.3-codex-spark': {
|
||||||
name: 'GPT-5.3 Codex Spark',
|
name: 'GPT-5.3 Codex Spark',
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 32000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {},
|
||||||
|
xhigh: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gpt-5.3-codex': {
|
||||||
|
name: 'GPT-5.3 Codex',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
store: false
|
store: false
|
||||||
},
|
},
|
||||||
@@ -548,6 +644,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
|||||||
},
|
},
|
||||||
'gpt-5.2-codex': {
|
'gpt-5.2-codex': {
|
||||||
name: 'GPT-5.2 Codex',
|
name: 'GPT-5.2 Codex',
|
||||||
|
limit: {
|
||||||
|
context: 400000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
store: false
|
store: false
|
||||||
},
|
},
|
||||||
@@ -557,30 +657,266 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
|||||||
high: {},
|
high: {},
|
||||||
xhigh: {}
|
xhigh: {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
'codex-mini-latest': {
|
||||||
|
name: 'Codex Mini',
|
||||||
|
limit: {
|
||||||
|
context: 200000,
|
||||||
|
output: 100000
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
store: false
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
low: {},
|
||||||
|
medium: {},
|
||||||
|
high: {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const geminiModels = {
|
const geminiModels = {
|
||||||
'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
|
'gemini-2.0-flash': {
|
||||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
name: 'Gemini 2.0 Flash',
|
||||||
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
|
limit: {
|
||||||
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
|
context: 1048576,
|
||||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-2.5-flash': {
|
||||||
|
name: 'Gemini 2.5 Flash',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-2.5-pro': {
|
||||||
|
name: 'Gemini 2.5 Pro',
|
||||||
|
limit: {
|
||||||
|
context: 2097152,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3-flash-preview': {
|
||||||
|
name: 'Gemini 3 Flash Preview',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3-pro-preview': {
|
||||||
|
name: 'Gemini 3 Pro Preview',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3.1-pro-preview': {
|
||||||
|
name: 'Gemini 3.1 Pro Preview',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const antigravityGeminiModels = {
|
const antigravityGeminiModels = {
|
||||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
'gemini-2.5-flash': {
|
||||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
|
name: 'Gemini 2.5 Flash',
|
||||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
limit: {
|
||||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
context: 1048576,
|
||||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
output: 65536
|
||||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
},
|
||||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
modalities: {
|
||||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'disable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-2.5-flash-lite': {
|
||||||
|
name: 'Gemini 2.5 Flash Lite',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-2.5-flash-thinking': {
|
||||||
|
name: 'Gemini 2.5 Flash (Thinking)',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3-flash': {
|
||||||
|
name: 'Gemini 3 Flash',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3.1-pro-low': {
|
||||||
|
name: 'Gemini 3.1 Pro Low',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3.1-pro-high': {
|
||||||
|
name: 'Gemini 3.1 Pro High',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'gemini-3.1-flash-image': {
|
||||||
|
name: 'Gemini 3.1 Flash Image',
|
||||||
|
limit: {
|
||||||
|
context: 1048576,
|
||||||
|
output: 65536
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image'],
|
||||||
|
output: ['image']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const claudeModels = {
|
const claudeModels = {
|
||||||
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
|
'claude-opus-4-6-thinking': {
|
||||||
'claude-sonnet-4-5-thinking': { name: 'Claude Sonnet 4.5 Thinking' },
|
name: 'Claude 4.6 Opus (Thinking)',
|
||||||
'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' }
|
limit: {
|
||||||
|
context: 200000,
|
||||||
|
output: 128000
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'claude-sonnet-4-6': {
|
||||||
|
name: 'Claude 4.6 Sonnet',
|
||||||
|
limit: {
|
||||||
|
context: 200000,
|
||||||
|
output: 64000
|
||||||
|
},
|
||||||
|
modalities: {
|
||||||
|
input: ['text', 'image', 'pdf'],
|
||||||
|
output: ['text']
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
thinking: {
|
||||||
|
budgetTokens: 24576,
|
||||||
|
type: 'enabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'gemini') {
|
if (platform === 'gemini') {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
|
||||||
export type AddMethod = 'oauth' | 'setup-token'
|
export type AddMethod = 'oauth' | 'setup-token'
|
||||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token'
|
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'session_token' | 'access_token'
|
||||||
|
|
||||||
export interface OAuthState {
|
export interface OAuthState {
|
||||||
authUrl: string
|
authUrl: string
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const openaiModels = [
|
|||||||
// GPT-5.2 系列
|
// GPT-5.2 系列
|
||||||
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||||
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||||
|
// GPT-5.3 系列
|
||||||
|
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
|
||||||
'chatgpt-4o-latest',
|
'chatgpt-4o-latest',
|
||||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
||||||
]
|
]
|
||||||
@@ -75,6 +77,7 @@ const soraModels = [
|
|||||||
const antigravityModels = [
|
const antigravityModels = [
|
||||||
// Claude 4.5+ 系列
|
// Claude 4.5+ 系列
|
||||||
'claude-opus-4-6',
|
'claude-opus-4-6',
|
||||||
|
'claude-opus-4-6-thinking',
|
||||||
'claude-opus-4-5-thinking',
|
'claude-opus-4-5-thinking',
|
||||||
'claude-sonnet-4-6',
|
'claude-sonnet-4-6',
|
||||||
'claude-sonnet-4-5',
|
'claude-sonnet-4-5',
|
||||||
@@ -88,10 +91,10 @@ const antigravityModels = [
|
|||||||
'gemini-3-flash',
|
'gemini-3-flash',
|
||||||
'gemini-3-pro-high',
|
'gemini-3-pro-high',
|
||||||
'gemini-3-pro-low',
|
'gemini-3-pro-low',
|
||||||
'gemini-3-pro-image',
|
|
||||||
// Gemini 3.1 系列
|
// Gemini 3.1 系列
|
||||||
'gemini-3.1-pro-high',
|
'gemini-3.1-pro-high',
|
||||||
'gemini-3.1-pro-low',
|
'gemini-3.1-pro-low',
|
||||||
|
'gemini-3.1-flash-image',
|
||||||
// 其他
|
// 其他
|
||||||
'gpt-oss-120b-medium',
|
'gpt-oss-120b-medium',
|
||||||
'tab_flash_lite_preview'
|
'tab_flash_lite_preview'
|
||||||
@@ -309,6 +312,7 @@ const antigravityPresetMappings = [
|
|||||||
// 精确映射
|
// 精确映射
|
||||||
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||||
|
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||||
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
{ label: 'Opus 4.6-thinking', from: 'claude-opus-4-6-thinking', to: 'claude-opus-4-6-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ export async function setLocale(locale: string): Promise<void> {
|
|||||||
i18n.global.locale.value = locale
|
i18n.global.locale.value = locale
|
||||||
localStorage.setItem(LOCALE_KEY, locale)
|
localStorage.setItem(LOCALE_KEY, locale)
|
||||||
document.documentElement.setAttribute('lang', locale)
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
|
||||||
|
// 同步更新浏览器页签标题,使其跟随语言切换
|
||||||
|
const { resolveDocumentTitle } = await import('@/router/title')
|
||||||
|
const { default: router } = await import('@/router')
|
||||||
|
const { useAppStore } = await import('@/stores/app')
|
||||||
|
const route = router.currentRoute.value
|
||||||
|
const appStore = useAppStore()
|
||||||
|
document.title = resolveDocumentTitle(route.meta.title, appStore.siteName, route.meta.titleKey as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocale(): LocaleCode {
|
export function getLocale(): LocaleCode {
|
||||||
|
|||||||
@@ -1133,7 +1133,7 @@ export default {
|
|||||||
},
|
},
|
||||||
imagePricing: {
|
imagePricing: {
|
||||||
title: 'Image Generation Pricing',
|
title: 'Image Generation Pricing',
|
||||||
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
|
description: 'Configure pricing for image generation models. Leave empty to use default prices.'
|
||||||
},
|
},
|
||||||
soraPricing: {
|
soraPricing: {
|
||||||
title: 'Sora Per-Request Pricing',
|
title: 'Sora Per-Request Pricing',
|
||||||
@@ -1505,7 +1505,8 @@ export default {
|
|||||||
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
|
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
|
||||||
failed: 'Bulk update failed',
|
failed: 'Bulk update failed',
|
||||||
noSelection: 'Please select accounts to edit',
|
noSelection: 'Please select accounts to edit',
|
||||||
noFieldsSelected: 'Select at least one field to update'
|
noFieldsSelected: 'Select at least one field to update',
|
||||||
|
mixedPlatformWarning: 'Selected accounts span multiple platforms ({platforms}). Model mapping presets shown are combined — ensure mappings are appropriate for each platform.'
|
||||||
},
|
},
|
||||||
bulkDeleteTitle: 'Bulk Delete Accounts',
|
bulkDeleteTitle: 'Bulk Delete Accounts',
|
||||||
bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.',
|
bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.',
|
||||||
@@ -2046,7 +2047,7 @@ export default {
|
|||||||
geminiFlashDaily: 'Flash',
|
geminiFlashDaily: 'Flash',
|
||||||
gemini3Pro: 'G3P',
|
gemini3Pro: 'G3P',
|
||||||
gemini3Flash: 'G3F',
|
gemini3Flash: 'G3F',
|
||||||
gemini3Image: 'G3I',
|
gemini3Image: 'GImage',
|
||||||
claude: 'Claude'
|
claude: 'Claude'
|
||||||
},
|
},
|
||||||
tier: {
|
tier: {
|
||||||
|
|||||||
@@ -1220,7 +1220,7 @@ export default {
|
|||||||
},
|
},
|
||||||
imagePricing: {
|
imagePricing: {
|
||||||
title: '图片生成计费',
|
title: '图片生成计费',
|
||||||
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
|
description: '配置图片生成模型的图片生成价格,留空则使用默认价格'
|
||||||
},
|
},
|
||||||
soraPricing: {
|
soraPricing: {
|
||||||
title: 'Sora 按次计费',
|
title: 'Sora 按次计费',
|
||||||
@@ -1582,7 +1582,7 @@ export default {
|
|||||||
geminiFlashDaily: 'Flash',
|
geminiFlashDaily: 'Flash',
|
||||||
gemini3Pro: 'G3P',
|
gemini3Pro: 'G3P',
|
||||||
gemini3Flash: 'G3F',
|
gemini3Flash: 'G3F',
|
||||||
gemini3Image: 'G3I',
|
gemini3Image: 'GImage',
|
||||||
claude: 'Claude'
|
claude: 'Claude'
|
||||||
},
|
},
|
||||||
tier: {
|
tier: {
|
||||||
@@ -1652,7 +1652,8 @@ export default {
|
|||||||
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
|
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
|
||||||
failed: '批量更新失败',
|
failed: '批量更新失败',
|
||||||
noSelection: '请选择要编辑的账号',
|
noSelection: '请选择要编辑的账号',
|
||||||
noFieldsSelected: '请至少选择一个要更新的字段'
|
noFieldsSelected: '请至少选择一个要更新的字段',
|
||||||
|
mixedPlatformWarning: '所选账号跨越多个平台({platforms})。显示的模型映射预设为合并结果——请确保映射对每个平台都适用。'
|
||||||
},
|
},
|
||||||
bulkDeleteTitle: '批量删除账号',
|
bulkDeleteTitle: '批量删除账号',
|
||||||
bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。',
|
bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。',
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/auth/LoginView.vue'),
|
component: () => import('@/views/auth/LoginView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
title: 'Login'
|
title: 'Login',
|
||||||
|
titleKey: 'common.login'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +51,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
title: 'Register'
|
title: 'Register',
|
||||||
|
titleKey: 'auth.createAccount'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,7 +88,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
title: 'Forgot Password'
|
title: 'Forgot Password',
|
||||||
|
titleKey: 'auth.forgotPasswordTitle'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -390,7 +393,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName)
|
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
|
||||||
|
|
||||||
// Check if route requires authentication
|
// Check if route requires authentication
|
||||||
const requiresAuth = to.meta.requiresAuth !== false // Default to true
|
const requiresAuth = to.meta.requiresAuth !== false // Default to true
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { i18n } from '@/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
|
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
|
||||||
|
* 优先使用 titleKey 通过 i18n 翻译,fallback 到静态 routeTitle。
|
||||||
*/
|
*/
|
||||||
export function resolveDocumentTitle(routeTitle: unknown, siteName?: string): string {
|
export function resolveDocumentTitle(routeTitle: unknown, siteName?: string, titleKey?: string): string {
|
||||||
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API'
|
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API'
|
||||||
|
|
||||||
|
if (typeof titleKey === 'string' && titleKey.trim()) {
|
||||||
|
const translated = i18n.global.t(titleKey)
|
||||||
|
if (translated && translated !== titleKey) {
|
||||||
|
return `${translated} - ${normalizedSiteName}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof routeTitle === 'string' && routeTitle.trim()) {
|
if (typeof routeTitle === 'string' && routeTitle.trim()) {
|
||||||
return `${routeTitle.trim()} - ${normalizedSiteName}`
|
return `${routeTitle.trim()} - ${normalizedSiteName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@
|
|||||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
||||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||||
@@ -303,7 +303,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
import type { Account, AccountPlatform, Proxy, AdminGroup } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -312,6 +312,14 @@ const authStore = useAuthStore()
|
|||||||
const proxies = ref<Proxy[]>([])
|
const proxies = ref<Proxy[]>([])
|
||||||
const groups = ref<AdminGroup[]>([])
|
const groups = ref<AdminGroup[]>([])
|
||||||
const selIds = ref<number[]>([])
|
const selIds = ref<number[]>([])
|
||||||
|
const selPlatforms = computed<AccountPlatform[]>(() => {
|
||||||
|
const platforms = new Set(
|
||||||
|
accounts.value
|
||||||
|
.filter(a => selIds.value.includes(a.id))
|
||||||
|
.map(a => a.platform)
|
||||||
|
)
|
||||||
|
return [...platforms]
|
||||||
|
})
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const showSync = ref(false)
|
const showSync = ref(false)
|
||||||
|
|||||||
@@ -459,7 +459,7 @@
|
|||||||
step="0.001"
|
step="0.001"
|
||||||
min="0"
|
min="0"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="0.134"
|
placeholder="0.201"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1139,7 +1139,7 @@
|
|||||||
step="0.001"
|
step="0.001"
|
||||||
min="0"
|
min="0"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="0.134"
|
placeholder="0.201"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,8 +17,43 @@
|
|||||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
|
||||||
<UsageTable :data="usageLogs" :loading="loading" />
|
<template #after-reset>
|
||||||
|
<div class="relative" ref="columnDropdownRef">
|
||||||
|
<button
|
||||||
|
@click="showColumnDropdown = !showColumnDropdown"
|
||||||
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
|
:title="t('admin.users.columnSettings')"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showColumnDropdown"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 max-h-80 w-48 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="col in toggleableColumns"
|
||||||
|
:key="col.key"
|
||||||
|
@click="toggleColumn(col.key)"
|
||||||
|
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
<span>{{ col.label }}</span>
|
||||||
|
<Icon
|
||||||
|
v-if="isColumnVisible(col.key)"
|
||||||
|
name="check"
|
||||||
|
size="sm"
|
||||||
|
class="text-primary-500"
|
||||||
|
:stroke-width="2"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UsageFilters>
|
||||||
|
<UsageTable :data="usageLogs" :loading="loading" :columns="visibleColumns" />
|
||||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -43,6 +78,7 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo
|
|||||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -141,6 +177,77 @@ const exportToExcel = async () => {
|
|||||||
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadLogs(); loadStats(); loadChartData() })
|
// Column visibility
|
||||||
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() })
|
const ALWAYS_VISIBLE = ['user', 'created_at']
|
||||||
|
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'user_agent']
|
||||||
|
const HIDDEN_COLUMNS_KEY = 'usage-hidden-columns'
|
||||||
|
|
||||||
|
const allColumns = computed(() => [
|
||||||
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
|
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
||||||
|
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||||
|
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||||||
|
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
||||||
|
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||||
|
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||||
|
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||||
|
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||||
|
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||||
|
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||||
|
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
|
||||||
|
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
|
||||||
|
])
|
||||||
|
|
||||||
|
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleableColumns = computed(() =>
|
||||||
|
allColumns.value.filter(col => !ALWAYS_VISIBLE.includes(col.key))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleColumns = computed(() =>
|
||||||
|
allColumns.value.filter(col =>
|
||||||
|
ALWAYS_VISIBLE.includes(col.key) || !hiddenColumns.has(col.key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
if (hiddenColumns.has(key)) {
|
||||||
|
hiddenColumns.delete(key)
|
||||||
|
} else {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save columns:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSavedColumns = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||||
|
if (saved) {
|
||||||
|
(JSON.parse(saved) as string[]).forEach(key => hiddenColumns.add(key))
|
||||||
|
} else {
|
||||||
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showColumnDropdown = ref(false)
|
||||||
|
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const handleColumnClickOutside = (event: MouseEvent) => {
|
||||||
|
if (columnDropdownRef.value && !columnDropdownRef.value.contains(event.target as HTMLElement)) {
|
||||||
|
showColumnDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadLogs(); loadStats(); loadChartData(); loadSavedColumns(); document.addEventListener('click', handleColumnClickOutside) })
|
||||||
|
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); document.removeEventListener('click', handleColumnClickOutside) })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user