mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-17 08:04:00 +08:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea725f2ea | ||
|
|
74431ffad8 | ||
|
|
0c9e83b59c | ||
|
|
09f88865e0 | ||
|
|
1b45d21328 | ||
|
|
d5cea617a5 | ||
|
|
4275047d16 | ||
|
|
4dbcdce43c | ||
|
|
6477143533 | ||
|
|
c63bd14a02 | ||
|
|
a8a1a3a958 | ||
|
|
db8692d679 | ||
|
|
bcc73f8d40 | ||
|
|
a9c7c6e8bf | ||
|
|
0f4d8acaa8 | ||
|
|
f79b8bf96c | ||
|
|
efe9aab1e4 | ||
|
|
d099c06945 | ||
|
|
aeab57b9b2 | ||
|
|
18328aed05 | ||
|
|
48a86ec926 | ||
|
|
b4e560824a | ||
|
|
9efc167482 | ||
|
|
5050471822 | ||
|
|
c84a3d50a3 | ||
|
|
cd1834e0a9 | ||
|
|
0932e8ce0a | ||
|
|
ffa43607e5 | ||
|
|
57e9f6a4b8 | ||
|
|
71c0eb5126 | ||
|
|
10fd1ada28 | ||
|
|
53fd5f8351 | ||
|
|
55bde0857f | ||
|
|
5f75e0d2a7 | ||
|
|
efd86c63e6 | ||
|
|
ea68c0e82c | ||
|
|
40e7ce7fbf | ||
|
|
d52ab27def | ||
|
|
7e4164c1cf | ||
|
|
44831bb854 | ||
|
|
131893e449 | ||
|
|
4daabc3d84 | ||
|
|
acc05620cb | ||
|
|
406be7c518 | ||
|
|
b51f0759d4 | ||
|
|
be313735dc | ||
|
|
017e9e98d1 | ||
|
|
611c30f04f | ||
|
|
be4a4990ff | ||
|
|
aba34524f1 | ||
|
|
881ab1b0c6 | ||
|
|
fe36f4a914 | ||
|
|
f8e5a0d94f | ||
|
|
0838e0e610 | ||
|
|
98321a054e | ||
|
|
ee9ac3e45f | ||
|
|
1a32b91eb2 | ||
|
|
18bfa4bf20 | ||
|
|
31f3003ff5 | ||
|
|
2f16e0984e | ||
|
|
a9ed218989 | ||
|
|
18d483c2a1 | ||
|
|
8e34ca5e31 | ||
|
|
130ba634ad | ||
|
|
6a4938bdfb | ||
|
|
14636d2f0e | ||
|
|
0ed735d3ba | ||
|
|
21532add46 | ||
|
|
d7ee1ab6be | ||
|
|
881f320269 | ||
|
|
da74bf13a4 | ||
|
|
86f93c28ee | ||
|
|
30ed40a56a | ||
|
|
b8275209e8 | ||
|
|
25c21aedda | ||
|
|
2d9af8592f | ||
|
|
8447bdd368 | ||
|
|
32682a4f84 | ||
|
|
55a95d4c67 | ||
|
|
a939553c87 | ||
|
|
badfad8b72 | ||
|
|
1be482c313 | ||
|
|
087987070b | ||
|
|
db76cc4e4e | ||
|
|
c0c4ce1ce6 | ||
|
|
e1eff57c25 |
@@ -133,6 +133,7 @@ deploy/docker-compose.override.yml
|
||||
.gocache/
|
||||
vite.config.js
|
||||
docs/*
|
||||
!docs/ANTIGRAVITY_ATTRIBUTION_429.md
|
||||
!docs/PAYMENT.md
|
||||
!docs/PAYMENT_CN.md
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
|
||||
@@ -49,8 +49,8 @@ Please read the following carefully before using this project:
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
|
||||
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -102,14 +102,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
|
||||
<td>🎉 Thanks to FastAIToken for sponsoring this project! <a href="http://www.fastaitoken.com/register">FastAIToken</a> is an AI API aggregation platform for developers, supporting mainstream large models such as OpenAI, Claude, and Gemini. Top-up at 1:1 — 1 CNY = 1 USD of API credit — letting developers use the world's leading large model services at lower cost and with greater convenience.<br>
|
||||
|
||||
🚀 The platform offers a variety of channels to choose from: an ultra-low-price 0.02x OpenAI promotional group (limited time), groups as low as 0.25x OpenAI, 0.7x Claude with 95% fixed cache, and a 1.2x Claude Max channel. It also provides a public status page showing real-time availability, latency, and operating status of each group for transparent and reliable service, plus 7×24 human technical support (not bots) with fast responses to developer needs.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
|
||||
|
||||
+2
-10
@@ -50,8 +50,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
|
||||
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -105,14 +105,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
|
||||
<td>🎉 感谢 FastAIToken 对本项目的赞助! <a href="http://www.fastaitoken.com/register">FastAIToken</a> 是面向开发者的 AI API 聚合平台,支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务。<br>
|
||||
|
||||
🚀 平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
|
||||
|
||||
+2
-10
@@ -49,8 +49,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
|
||||
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
|
||||
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -104,14 +104,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
|
||||
<td>🎉 FastAIToken のご支援に感謝します!<a href="http://www.fastaitoken.com/register">FastAIToken</a> は開発者向けの AI API アグリゲーションプラットフォームで、OpenAI、Claude、Gemini などの主要な大規模モデルに対応しています。チャージは 1:1(1 元 = 1 米ドル分の API クレジット)で、開発者がより低コスト・より手軽に世界トップクラスの大規模モデルサービスを利用できます。<br>
|
||||
|
||||
🚀 プラットフォームでは多彩なチャネルを自由に選択できます:超低価格の 0.02x OpenAI 特典グループ(期間限定)、最低 0.25x の OpenAI グループ、0.7x Claude(95% 固定キャッシュ)、1.2x Claude Max チャネル。さらに、各グループの可用率・レイテンシ・稼働状況をリアルタイムで表示する公開ステータスページを提供し、透明で信頼できるサービスを実現。7×24 時間の有人テクニカルサポート(ボットではありません)により、開発者のニーズに迅速に対応します。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2.4
|
||||
0.2.5
|
||||
|
||||
@@ -204,7 +204,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
pluginRepository := repository.NewPluginRepository(db)
|
||||
pluginHostInfo := providePluginHostInfo(buildInfo)
|
||||
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
|
||||
pluginKVStore := repository.NewPluginKVStore(redisClient)
|
||||
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore)
|
||||
pluginManager.SetAccountDirectory(openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
|
||||
+14
-14
@@ -50,13 +50,13 @@ require (
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
github.com/zeromicro/go-zero v1.9.4
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -192,20 +192,20 @@ require (
|
||||
github.com/zclconf/go-cty v1.14.4 // indirect
|
||||
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
+36
-49
@@ -253,8 +253,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -300,8 +298,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -329,8 +325,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -369,8 +363,6 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -405,8 +397,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -442,8 +432,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
@@ -521,30 +509,30 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8
|
||||
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
|
||||
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
|
||||
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
@@ -574,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
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=
|
||||
@@ -590,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -613,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -626,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -645,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -660,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -674,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -686,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -702,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -714,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@@ -183,6 +183,22 @@ func (h *PluginHandler) Test(c *gin.Context) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// Status returns the plugin's passive runtime status for the config UI. It is
|
||||
// read-only (no config apply, no upstream call) and therefore not step-up gated,
|
||||
// so a status UI can poll it without a 2FA prompt or a "test" side effect.
|
||||
func (h *PluginHandler) Status(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.manager.Status(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *PluginHandler) CreateUISession(c *gin.Context) {
|
||||
id, ok := pluginIDParam(c)
|
||||
if !ok {
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
EndpointVideosEdits = "/v1/videos/edits"
|
||||
EndpointVideosExtensions = "/v1/videos/extensions"
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
)
|
||||
|
||||
@@ -81,6 +82,8 @@ const (
|
||||
func NormalizeInboundEndpoint(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
switch {
|
||||
case strings.Contains(path, "/contents/generations/tasks"):
|
||||
return EndpointSeedanceTasks
|
||||
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
|
||||
return EndpointResponsesInputTokens
|
||||
case strings.Contains(path, EndpointEmbeddings):
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
|
||||
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
|
||||
41: {{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Status: service.StatusActive, Schedulable: true}},
|
||||
},
|
||||
}}
|
||||
h := &GatewayHandler{
|
||||
@@ -109,7 +109,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
|
||||
Platform: service.PlatformGemini,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
|
||||
Models: []string{"gemini-3.8-flash-high", "gemini-3-*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -129,8 +129,8 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
|
||||
names = append(names, model.Name)
|
||||
}
|
||||
// models/ 前缀的候选形式也应命中条目。
|
||||
require.Contains(t, names, "models/gemini-2.5-pro")
|
||||
require.Contains(t, names, "models/gemini-3-pro-preview")
|
||||
require.Contains(t, names, "models/gemini-3.8-flash-high")
|
||||
require.Contains(t, names, "models/gemini-3-flash")
|
||||
require.NotContains(t, names, "models/gemini-2.5-flash")
|
||||
require.NotContains(t, names, "models/gemini-2.0-flash")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
forced, mixed, allowlist bool
|
||||
status int
|
||||
}{
|
||||
{"mixed", false, true, false, 200},
|
||||
{"mixed allowlist", false, true, true, 200},
|
||||
{"disabled mixed", false, false, false, 503},
|
||||
{"forced without mixed opt-in", true, false, false, 200},
|
||||
{"forced allowlist", true, false, true, 200},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
groupID := int64(45)
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAntigravity,
|
||||
Extra: map[string]any{"mixed_scheduling": tt.mixed},
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}},
|
||||
}}}
|
||||
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}},
|
||||
}})
|
||||
if tt.forced {
|
||||
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
|
||||
}
|
||||
h.GeminiV1BetaListModels(c)
|
||||
require.Equal(t, tt.status, rec.Code, rec.Body.String())
|
||||
if tt.status != 200 {
|
||||
return
|
||||
}
|
||||
var got gemini.ModelsListResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
names := []string{}
|
||||
for _, model := range got.Models {
|
||||
names = append(names, model.Name)
|
||||
require.Contains(t, model.SupportedGenerationMethods, "generateContent")
|
||||
}
|
||||
require.Contains(t, names, "models/gemini-synced-custom")
|
||||
require.NotContains(t, names, "models/claude-custom")
|
||||
require.NotContains(t, names, "models/gemini-2.0-flash")
|
||||
if tt.allowlist {
|
||||
require.Equal(t, []string{"models/gemini-synced-custom"}, names)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`)
|
||||
extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")}
|
||||
merged, ok := appendUpstreamGeminiModels(body, extra)
|
||||
require.True(t, ok)
|
||||
require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged))
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}})
|
||||
require.True(t, ok)
|
||||
require.True(t, dropped)
|
||||
require.NotContains(t, string(filtered), "gemini-native")
|
||||
require.Contains(t, string(filtered), "gemini-synced-custom")
|
||||
for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} {
|
||||
got, ok := appendUpstreamGeminiModels([]byte(invalid), extra)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, invalid, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real handler's native-upstream branch, including scope fallback.
|
||||
type geminiMixedModelsUpstream struct {
|
||||
service.HTTPUpstream
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil
|
||||
}
|
||||
func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
expectedNative string
|
||||
}{
|
||||
{"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"},
|
||||
{"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"},
|
||||
{"upstream error", 429, `{"error":"rate limited"}`, ""},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id := int64(46)
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: {
|
||||
{ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}},
|
||||
{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}},
|
||||
}}}}
|
||||
upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body}
|
||||
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}})
|
||||
h.GeminiV1BetaListModels(c)
|
||||
if tt.status == 429 {
|
||||
require.Equal(t, 429, rec.Code)
|
||||
require.JSONEq(t, tt.body, rec.Body.String())
|
||||
return
|
||||
}
|
||||
require.Equal(t, 200, rec.Code, rec.Body.String())
|
||||
require.Contains(t, rec.Body.String(), tt.expectedNative)
|
||||
require.Contains(t, rec.Body.String(), "models/gemini-synced-custom")
|
||||
if tt.status == 200 {
|
||||
require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`)
|
||||
require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`)
|
||||
require.Equal(t, "native-id", rec.Header().Get("X-Request-Id"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,30 +60,24 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
|
||||
agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models")
|
||||
return
|
||||
}
|
||||
agModels := make([]gemini.Model, 0, len(agModelIDs))
|
||||
for _, id := range agModelIDs {
|
||||
agModels = append(agModels, gemini.FallbackModel(id))
|
||||
}
|
||||
if forcePlatform == service.PlatformAntigravity {
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
agModels := antigravity.DefaultGeminiModels()
|
||||
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
|
||||
for _, model := range agModels {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
// 没有 gemini 账户,检查是否有 antigravity 账户可用
|
||||
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
|
||||
if hasAntigravity {
|
||||
// antigravity 账户使用静态模型列表
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
if len(agModels) > 0 {
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
|
||||
return
|
||||
}
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
@@ -97,9 +91,15 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if shouldFallbackGeminiModels(res) {
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))})
|
||||
return
|
||||
}
|
||||
if res.StatusCode == http.StatusOK && len(agModels) > 0 {
|
||||
if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok {
|
||||
res.Body = merged
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
|
||||
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
|
||||
@@ -110,6 +110,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
// mergeGeminiModelLists keeps native metadata when both sources advertise a model.
|
||||
func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model {
|
||||
result := append([]gemini.Model{}, native...)
|
||||
seen := make(map[string]bool, len(native))
|
||||
for _, model := range native {
|
||||
seen[model.Name] = true
|
||||
}
|
||||
for _, model := range extra {
|
||||
if !seen[model.Name] {
|
||||
result = append(result, model)
|
||||
seen[model.Name] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields.
|
||||
func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &envelope) != nil || envelope == nil {
|
||||
return body, false
|
||||
}
|
||||
var models []json.RawMessage
|
||||
raw, exists := envelope["models"]
|
||||
if !exists || json.Unmarshal(raw, &models) != nil {
|
||||
return body, false
|
||||
}
|
||||
seen := make(map[string]bool, len(models))
|
||||
for _, raw := range models {
|
||||
var model struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(raw, &model) != nil {
|
||||
return body, false
|
||||
}
|
||||
seen[model.Name] = true
|
||||
}
|
||||
changed := false
|
||||
for _, model := range extra {
|
||||
if seen[model.Name] {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(model)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
models = append(models, raw)
|
||||
seen[model.Name] = true
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, true
|
||||
}
|
||||
envelope["models"], _ = json.Marshal(models)
|
||||
merged, err := json.Marshal(envelope)
|
||||
return merged, err == nil
|
||||
}
|
||||
|
||||
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
|
||||
// models[].name,其余信封字段(如 nextPageToken)原样保留。
|
||||
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
|
||||
|
||||
@@ -39,7 +39,11 @@ func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T)
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaListModels(c)
|
||||
groupID := int64(42)
|
||||
key, _ := middleware.GetAPIKeyFromContext(c)
|
||||
key.GroupID = &groupID
|
||||
repo := &geminiAllowlistAccountRepoStub{}
|
||||
(&GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}).GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got antigravity.GeminiModelsListResponse
|
||||
|
||||
@@ -52,6 +52,12 @@ func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
|
||||
platform := service.PlatformGrok
|
||||
noAccountCode, noAccountMessage := "grok_media_no_eligible_account", "No eligible Grok media accounts"
|
||||
if endpoint.IsSeedance() {
|
||||
platform = service.PlatformOpenAI
|
||||
noAccountCode, noAccountMessage = "seedance_no_eligible_account", "No eligible Seedance accounts"
|
||||
}
|
||||
streamStarted := false
|
||||
defer h.recoverResponsesPanic(c, &streamStarted)
|
||||
|
||||
@@ -99,6 +105,13 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
requestInfo := service.ParseGrokMediaRequest(contentType, body)
|
||||
if endpoint == service.SeedanceEndpointCreate {
|
||||
requestInfo, err = service.ParseSeedanceRequest(body)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
requestModel := requestInfo.Model
|
||||
routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage())
|
||||
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
|
||||
@@ -215,8 +228,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
var selection *service.AccountSelectionResult
|
||||
var scheduleDecision service.OpenAIAccountScheduleDecision
|
||||
if boundLookupAccountID > 0 {
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount(
|
||||
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel,
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount(
|
||||
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform,
|
||||
)
|
||||
} else {
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
@@ -231,7 +244,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
platform,
|
||||
)
|
||||
}
|
||||
// Own an eagerly acquired slot before any rejection or eligibility probe.
|
||||
@@ -256,11 +269,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) &&
|
||||
(len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
|
||||
return
|
||||
}
|
||||
if len(failedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
@@ -277,10 +290,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
if selection == nil || selection.Account == nil {
|
||||
if endpoint.IsGenerationRequest() {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
|
||||
return
|
||||
}
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
@@ -309,7 +322,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
)
|
||||
|
||||
account := selection.Account
|
||||
if endpoint.IsGenerationRequest() {
|
||||
if endpoint.IsGenerationRequest() && !endpoint.IsSeedance() {
|
||||
eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account)
|
||||
if !eligible {
|
||||
releaseAccount()
|
||||
@@ -360,6 +373,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
writerSizeBeforeForward := c.Writer.Size()
|
||||
result, err := func() (*service.OpenAIForwardResult, error) {
|
||||
defer releaseAccount()
|
||||
if endpoint.IsSeedance() {
|
||||
return h.gatewayService.ForwardSeedance(requestCtx, c, account, endpoint, requestID, body)
|
||||
}
|
||||
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
|
||||
}()
|
||||
|
||||
@@ -490,7 +506,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
}
|
||||
// Status poll OR content download can observe official done+video.url.
|
||||
// Both paths share the same claim key so the customer is charged once.
|
||||
if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
|
||||
if endpoint == service.SeedanceEndpointStatus {
|
||||
if billResult := prepareSeedanceCompletionBilling(requestCtx, h, apiKey, subject, requestID, result); billResult != nil {
|
||||
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID)
|
||||
}
|
||||
} else if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
|
||||
taskID := strings.TrimSpace(requestID)
|
||||
if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil {
|
||||
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID)
|
||||
@@ -521,6 +541,9 @@ func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Con
|
||||
}
|
||||
|
||||
func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability {
|
||||
if endpoint.IsSeedance() {
|
||||
return service.OpenAIEndpointCapabilitySeedance
|
||||
}
|
||||
if endpoint.IsGenerationRequest() {
|
||||
return service.OpenAIEndpointCapabilityGrokMediaGeneration
|
||||
}
|
||||
@@ -539,7 +562,7 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
|
||||
|
||||
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
|
||||
switch endpoint {
|
||||
case service.GrokMediaEndpointVideosGenerations,
|
||||
case service.SeedanceEndpointCreate, service.GrokMediaEndpointVideosGenerations,
|
||||
service.GrokMediaEndpointVideosEdits,
|
||||
service.GrokMediaEndpointVideosExtensions:
|
||||
return true
|
||||
@@ -712,7 +735,7 @@ func recordGrokMediaUsage(
|
||||
}
|
||||
// Async video: force durable task request id and release claim if billing fails.
|
||||
videoTaskID := ""
|
||||
if result != nil && result.VideoCount > 0 {
|
||||
if result != nil && (result.VideoCount > 0 || strings.HasPrefix(result.ResponseID, "seedance:")) {
|
||||
videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID))
|
||||
if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" {
|
||||
result.RequestID = stable
|
||||
|
||||
@@ -120,10 +120,22 @@ func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
|
||||
|
||||
type grokMediaSlotBindings struct {
|
||||
testutil.StubGatewayCache
|
||||
owner int64
|
||||
writes int
|
||||
key string
|
||||
billed map[string]bool
|
||||
owner int64
|
||||
writes int
|
||||
key string
|
||||
billed map[string]bool
|
||||
pending map[string][]byte
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotBindings) SetGrokVideoPendingBilling(_ context.Context, key string, body []byte, _ time.Duration) error {
|
||||
if s.pending == nil {
|
||||
s.pending = make(map[string][]byte)
|
||||
}
|
||||
s.pending[key] = append([]byte(nil), body...)
|
||||
return nil
|
||||
}
|
||||
func (s *grokMediaSlotBindings) GetGrokVideoPendingBilling(_ context.Context, key string) ([]byte, error) {
|
||||
return s.pending[key], nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) {
|
||||
@@ -186,7 +198,7 @@ func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64
|
||||
return p(ctx, id)
|
||||
}
|
||||
|
||||
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
|
||||
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool, platforms ...string) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
accounts := make([]service.Account, 3)
|
||||
@@ -194,6 +206,11 @@ func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGateway
|
||||
accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i,
|
||||
GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}}
|
||||
if len(platforms) > 0 {
|
||||
accounts[i].Platform = platforms[0]
|
||||
accounts[i].Credentials["base_url"] = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
accounts[i].Credentials["openai_capabilities"] = []string{"seedance"}
|
||||
}
|
||||
if oauth {
|
||||
accounts[i].Type = service.AccountTypeOAuth
|
||||
accounts[i].Credentials["refresh_token"] = "test-refresh"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -68,6 +71,39 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the legacy array response for clients that do not request pagination.
|
||||
if c.Request.URL.Query().Has("page") || c.Request.URL.Query().Has("page_size") {
|
||||
page, pageSize := 1, 20
|
||||
for name, target := range map[string]*int{"page": &page, "page_size": &pageSize} {
|
||||
if raw, exists := c.GetQuery(name); exists {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
response.BadRequest(c, "Invalid "+name)
|
||||
return
|
||||
}
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
if page-1 > int(^uint(0)>>1)/pageSize {
|
||||
response.BadRequest(c, "Invalid page")
|
||||
return
|
||||
}
|
||||
codes, result, err := h.redeemService.GetUserHistoryPaginated(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: page, PageSize: pageSize})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]dto.RedeemCode, 0, len(codes))
|
||||
for i := range codes {
|
||||
out = append(out, *dto.RedeemCodeFromService(&codes[i]))
|
||||
}
|
||||
response.Paginated(c, out, result.Total, page, pageSize)
|
||||
return
|
||||
}
|
||||
|
||||
// Default limit is 25
|
||||
limit := 25
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type historyRepo struct {
|
||||
service.RedeemCodeRepository
|
||||
userID int64
|
||||
params pagination.PaginationParams
|
||||
legacyLimit int
|
||||
}
|
||||
|
||||
func (r *historyRepo) ListByUser(_ context.Context, userID int64, limit int) ([]service.RedeemCode, error) {
|
||||
r.userID, r.legacyLimit = userID, limit
|
||||
return []service.RedeemCode{}, nil
|
||||
}
|
||||
func (r *historyRepo) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
|
||||
r.userID, r.params = userID, params
|
||||
return []service.RedeemCode{}, &pagination.PaginationResult{Total: 101}, nil
|
||||
}
|
||||
func TestRedeemHistory(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, query string
|
||||
userID int64
|
||||
status, page, size int
|
||||
}{
|
||||
{"legacy", "", 7, 200, 0, 0},
|
||||
{"default", "?page=1", 7, 200, 1, 20},
|
||||
{"size only", "?page_size=50", 7, 200, 1, 50},
|
||||
{"second user", "?page=2&page_size=100&user_id=7", 8, 200, 2, 100},
|
||||
{"cap", "?page_size=101", 7, 200, 1, 100},
|
||||
{"beyond last", "?page=100&page_size=20", 7, 200, 100, 20},
|
||||
{"zero", "?page=0", 7, 400, 0, 0},
|
||||
{"negative", "?page_size=-1", 7, 400, 0, 0},
|
||||
{"invalid", "?page=abc", 7, 400, 0, 0},
|
||||
{"overflow", "?page=9223372036854775807", 7, 400, 0, 0},
|
||||
{"unauthenticated", "?page=1", 0, 401, 0, 0},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &historyRepo{}
|
||||
h := NewRedeemHandler(service.NewRedeemService(repo, nil, nil, nil, nil, nil, nil, nil))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/redeem/history"+tt.query, nil)
|
||||
if tt.userID != 0 {
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: tt.userID})
|
||||
}
|
||||
h.GetHistory(c)
|
||||
require.Equal(t, tt.status, w.Code)
|
||||
if tt.status != 200 {
|
||||
require.Zero(t, repo.userID)
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.userID, repo.userID)
|
||||
var body struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
if tt.query == "" {
|
||||
require.Equal(t, 25, repo.legacyLimit)
|
||||
require.JSONEq(t, "[]", string(body.Data))
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.page, repo.params.Page)
|
||||
require.Equal(t, tt.size, repo.params.PageSize)
|
||||
var data struct {
|
||||
Items []service.RedeemCode `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"page_size"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body.Data, &data))
|
||||
require.NotNil(t, data.Items)
|
||||
require.Equal(t, 101, data.Total)
|
||||
require.Equal(t, tt.page, data.Page)
|
||||
require.Equal(t, tt.size, data.Size)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SeedanceTasks exposes Ark's native asynchronous video task protocol.
|
||||
func (h *OpenAIGatewayHandler) SeedanceTasks(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodPost && c.GetHeader("Content-Type") != "" {
|
||||
mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
h.errorResponse(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Seedance requires application/json")
|
||||
return
|
||||
}
|
||||
}
|
||||
key, ok := middleware.GetAPIKeyFromContext(c)
|
||||
if !ok || key.Group == nil || (key.Group.Platform != service.PlatformOpenAI && key.Group.Platform != service.PlatformComposite) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", "Seedance requires an OpenAI or composite group")
|
||||
return
|
||||
}
|
||||
endpoint := service.SeedanceEndpointCreate
|
||||
setActualUpstreamEndpoint(c, EndpointSeedanceTasks)
|
||||
taskID := ""
|
||||
if c.Request.Method != http.MethodPost {
|
||||
taskID = service.SeedanceTaskKey(c.Param("task_id"))
|
||||
endpoint = service.SeedanceEndpointStatus
|
||||
if c.Request.Method == http.MethodDelete {
|
||||
endpoint = service.SeedanceEndpointDelete
|
||||
}
|
||||
}
|
||||
h.handleGrokMedia(c, endpoint, taskID)
|
||||
}
|
||||
|
||||
// Ark reports actual completion tokens. Never infer tokens from duration or use
|
||||
// Grok's per-second video tariff. Repeated polls share the durable task dedup key.
|
||||
func prepareSeedanceCompletionBilling(ctx context.Context, h *OpenAIGatewayHandler, key *service.APIKey, subject middleware.AuthSubject, taskID string, result *service.OpenAIForwardResult) *service.OpenAIForwardResult {
|
||||
if result == nil || result.Usage.OutputTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
pending, err := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskID, subject.UserID, key.ID)
|
||||
if err != nil || pending == nil {
|
||||
return nil
|
||||
}
|
||||
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskID, subject.UserID, key.ID)
|
||||
if err != nil || !claimed {
|
||||
return nil
|
||||
}
|
||||
merged := *result
|
||||
merged.Model = pending.Model
|
||||
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model)
|
||||
merged.UpstreamModel = firstNonEmptyString(pending.UpstreamModel, result.UpstreamModel)
|
||||
merged.RequestID = service.StableGrokVideoBillingRequestID(taskID)
|
||||
merged.ResponseID = taskID
|
||||
merged.Duration = service.GrokVideoE2EDuration(pending.CreatedAt, time.Now())
|
||||
return &merged
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSeedanceHandlerLifecycleAndOwnership(t *testing.T) {
|
||||
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false, service.PlatformOpenAI)
|
||||
var owner int64
|
||||
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
|
||||
body := `{"id":"task-ark","status":"queued"}`
|
||||
if req.Method == http.MethodPost {
|
||||
owner = id
|
||||
} else {
|
||||
require.Equal(t, owner, id)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
newContext := func(method string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
c, w := grokMediaSlotContext(context.Background(), method == http.MethodPost)
|
||||
key, _ := middleware.GetAPIKeyFromContext(c)
|
||||
key.Group.Platform = service.PlatformOpenAI
|
||||
body := ""
|
||||
if method == http.MethodPost {
|
||||
body = `{"model":"doubao-seedance","content":[{"type":"text","text":"waves"}]}`
|
||||
}
|
||||
c.Request = httptest.NewRequest(method, "/api/v3/contents/generations/tasks", strings.NewReader(body))
|
||||
c.Params = gin.Params{{Key: "task_id", Value: "task-ark"}}
|
||||
return c, w
|
||||
}
|
||||
c, w := newContext(http.MethodPost)
|
||||
h.SeedanceTasks(c)
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Positive(t, owner)
|
||||
require.Len(t, bindings.pending, 1)
|
||||
slots.assertReleased(t)
|
||||
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
||||
c, w = newContext(method)
|
||||
h.SeedanceTasks(c)
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
slots.assertReleased(t)
|
||||
}
|
||||
for _, other := range []string{"user", "key", "group", "task", "provider"} {
|
||||
c, w = newContext(http.MethodGet)
|
||||
key, _ := middleware.GetAPIKeyFromContext(c)
|
||||
switch other {
|
||||
case "user":
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 11, Concurrency: 5})
|
||||
case "key":
|
||||
key.ID = 21
|
||||
case "group":
|
||||
group := int64(25)
|
||||
key.GroupID = &group
|
||||
case "task":
|
||||
c.Params = gin.Params{{Key: "task_id", Value: "other"}}
|
||||
case "provider":
|
||||
c.Params = gin.Params{{Key: "request_id", Value: "task-ark"}}
|
||||
}
|
||||
before := upstream.calls
|
||||
if other == "provider" {
|
||||
h.GrokVideoStatus(c)
|
||||
} else {
|
||||
h.SeedanceTasks(c)
|
||||
}
|
||||
require.Equal(t, 404, w.Code, other+": "+w.Body.String())
|
||||
require.Equal(t, before, upstream.calls)
|
||||
slots.assertReleased(t)
|
||||
}
|
||||
c, _ = newContext(http.MethodGet)
|
||||
key, _ := middleware.GetAPIKeyFromContext(c)
|
||||
subject, _ := middleware.GetAuthSubjectFromContext(c)
|
||||
result := &service.OpenAIForwardResult{Usage: service.OpenAIUsage{OutputTokens: 12345}, ResponseID: "seedance:task-ark"}
|
||||
for i := range 20 {
|
||||
billed := prepareSeedanceCompletionBilling(context.Background(), h, key, subject, result.ResponseID, result)
|
||||
if i == 0 {
|
||||
require.NotNil(t, billed)
|
||||
require.Equal(t, "doubao-seedance", billed.BillingModel)
|
||||
require.Equal(t, 12345, billed.Usage.OutputTokens)
|
||||
require.Zero(t, billed.VideoCount)
|
||||
} else {
|
||||
require.Nil(t, billed)
|
||||
}
|
||||
}
|
||||
require.Len(t, bindings.billed, 1)
|
||||
}
|
||||
@@ -33,10 +33,13 @@ type responsesFailedBody struct {
|
||||
}
|
||||
|
||||
// responsesFailedEvent 是写入 SSE data 行的顶层结构。
|
||||
// 故意不带 sequence_number:spec 标记可选,且本函数被调用时无法可靠拿到 last seq。
|
||||
// SequenceNumber 不带 omitempty:grok-build 等 Rust serde 客户端把它当必填字段,
|
||||
// 缺失即 `missing field 'sequence_number'`。拿不到上一帧序号时写 0——这是终止事件,
|
||||
// 后面不会再续流。
|
||||
type responsesFailedEvent struct {
|
||||
Type string `json:"type"`
|
||||
Response responsesFailedBody `json:"response"`
|
||||
Type string `json:"type"`
|
||||
SequenceNumber int `json:"sequence_number"`
|
||||
Response responsesFailedBody `json:"response"`
|
||||
}
|
||||
|
||||
// writeResponsesFailedSSE emits a `response.failed` SSE event in the OpenAI
|
||||
@@ -49,8 +52,7 @@ type responsesFailedEvent struct {
|
||||
// 而抛出 "stream closed before response.completed"。
|
||||
//
|
||||
// 字段集对齐 apicompat.makeResponsesCompletedEvent:id/object/model/status/output/error。
|
||||
// 故意不写 sequence_number:本函数被调用时无法可靠拿到当前流的 last sequence,
|
||||
// 而 OpenAI spec 将 sequence_number 设为可选;省略避免破坏单调性约束。
|
||||
// sequence_number 始终写出(未知时为 0):OpenAI spec 标可选,但 grok-build 当必填。
|
||||
//
|
||||
// 返回 true 表示已尝试 SSE 写出(不论 Write 是否成功,caller 都应直接 return)。
|
||||
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
|
||||
|
||||
@@ -48,9 +48,12 @@ func parseResponsesFailedSSE(t *testing.T, body string) (map[string]any, map[str
|
||||
require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed), "data must be valid JSON: %s", jsonStr)
|
||||
|
||||
assert.Equal(t, "response.failed", parsed["type"])
|
||||
// 故意不发 sequence_number,避免与后续真实事件的序号冲突。
|
||||
_, hasSeq := parsed["sequence_number"]
|
||||
assert.False(t, hasSeq, "synthetic event must not emit sequence_number")
|
||||
// grok-build 把 sequence_number 当必填;合成终止事件未知上一帧时写 0。
|
||||
rawSeq, hasSeq := parsed["sequence_number"]
|
||||
assert.True(t, hasSeq, "synthetic event must emit sequence_number")
|
||||
seq, ok := rawSeq.(float64)
|
||||
assert.True(t, ok, "sequence_number must be a number, got %T", rawSeq)
|
||||
assert.GreaterOrEqual(t, seq, float64(0))
|
||||
|
||||
resp, ok := parsed["response"].(map[string]any)
|
||||
require.True(t, ok, "response object missing")
|
||||
@@ -316,7 +319,20 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t
|
||||
assert.Equal(t, "rate_limit_exceeded", errObj["code"])
|
||||
}
|
||||
|
||||
// Synthesized response.failed id falls back to uuid when no request_id is present.
|
||||
// issue #7128:grok-build 把顶层 sequence_number 当必填,合成的 response.failed
|
||||
// 必须写出该字段,否则整轮反序列化失败。
|
||||
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesSequenceNumber(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
|
||||
|
||||
require.True(t, strings.HasPrefix(w.Body.String(), "event: response.failed\n"))
|
||||
_, payload, found := strings.Cut(w.Body.String(), "data: ")
|
||||
require.True(t, found)
|
||||
require.True(t, gjson.Get(payload, "sequence_number").Exists(), "response.failed 必须带 sequence_number")
|
||||
require.GreaterOrEqual(t, gjson.Get(payload, "sequence_number").Int(), int64(0))
|
||||
}
|
||||
|
||||
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
|
||||
// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
|
||||
// (给客户端一个可识别的终止事件而不是盲重连)就落空了。
|
||||
@@ -333,6 +349,7 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *t
|
||||
assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
|
||||
}
|
||||
|
||||
// Synthesized response.failed id falls back to uuid when no request_id is present.
|
||||
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
|
||||
c, _ := newGinContextForEndpoint(t, EndpointResponses)
|
||||
id := synthesizeResponseID(c)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) {
|
||||
const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;"
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{"metadata only", attribution, ""},
|
||||
{"metadata with newline", attribution + "\n", ""},
|
||||
{"leading whitespace", " \t\n" + attribution, ""},
|
||||
{"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."},
|
||||
{"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"},
|
||||
{"CR", attribution + "\rKeep these instructions.", "Keep these instructions."},
|
||||
{"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."},
|
||||
{"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"},
|
||||
{"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"},
|
||||
{"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"},
|
||||
{"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution},
|
||||
{"later line", "Example:\n" + attribution, "Example:\n" + attribution},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
for _, arrayForm := range []bool{false, true} {
|
||||
name := tt.name + "/string"
|
||||
var system any = tt.text
|
||||
if arrayForm {
|
||||
name = tt.name + "/array"
|
||||
system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}}
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
systemJSON, err := json.Marshal(system)
|
||||
require.NoError(t, err)
|
||||
userJSON, err := json.Marshal(attribution)
|
||||
require.NoError(t, err)
|
||||
input := &ClaudeRequest{
|
||||
Model: "gemini-3.8-flash-high", System: systemJSON,
|
||||
Messages: []ClaudeMessage{{Role: "user", Content: userJSON}},
|
||||
Tools: []ClaudeTool{{Name: "example", Description: attribution,
|
||||
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}},
|
||||
}
|
||||
before, err := json.Marshal(input)
|
||||
require.NoError(t, err)
|
||||
body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{})
|
||||
require.NoError(t, err)
|
||||
var got V1InternalRequest
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
var texts []string
|
||||
for _, part := range got.Request.SystemInstruction.Parts {
|
||||
if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" {
|
||||
texts = append(texts, part.Text)
|
||||
}
|
||||
}
|
||||
var want []string
|
||||
if tt.want != "" {
|
||||
want = append(want, tt.want)
|
||||
}
|
||||
if arrayForm {
|
||||
want = append(want, "Keep the next block.")
|
||||
}
|
||||
require.Equal(t, want, texts)
|
||||
// The same literal text in user content and tool descriptions is not metadata.
|
||||
require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text)
|
||||
require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description)
|
||||
after, err := json.Marshal(input)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(before), string(after), "do not mutate the incoming request")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) {
|
||||
system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.")
|
||||
require.NoError(t, err)
|
||||
got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil)
|
||||
require.Len(t, got.Parts, 1)
|
||||
require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text)
|
||||
require.False(t, strings.Contains(got.Parts[0].Text, "cc_version="))
|
||||
}
|
||||
@@ -293,6 +293,26 @@ func filterOpenCodePrompt(text string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripClaudeAttribution removes the leading Claude Code attribution metadata line
|
||||
// from Antigravity system text. This is prompt metadata, not an HTTP header; it
|
||||
// can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the
|
||||
// Antigravity transformer: native Anthropic OAuth may require the attribution.
|
||||
func stripClaudeAttribution(text string) string {
|
||||
trimmed := strings.TrimLeft(text, " \t\r\n")
|
||||
if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") {
|
||||
return text
|
||||
}
|
||||
end := strings.IndexAny(trimmed, "\r\n")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := trimmed[end+1:]
|
||||
if trimmed[end] == '\r' {
|
||||
rest = strings.TrimPrefix(rest, "\n")
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
|
||||
func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent {
|
||||
var parts []GeminiPart
|
||||
@@ -305,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
|
||||
// 尝试解析为字符串
|
||||
var sysStr string
|
||||
if err := json.Unmarshal(system, &sysStr); err == nil {
|
||||
sysStr = stripClaudeAttribution(sysStr)
|
||||
if strings.TrimSpace(sysStr) != "" {
|
||||
if strings.Contains(sysStr, "You are Antigravity") {
|
||||
userHasAntigravityIdentity = true
|
||||
@@ -320,6 +341,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
|
||||
var sysBlocks []SystemBlock
|
||||
if err := json.Unmarshal(system, &sysBlocks); err == nil {
|
||||
for _, block := range sysBlocks {
|
||||
block.Text = stripClaudeAttribution(block.Text)
|
||||
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
|
||||
if strings.Contains(block.Text, "You are Antigravity") {
|
||||
userHasAntigravityIdentity = true
|
||||
|
||||
@@ -132,6 +132,31 @@ func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
|
||||
require.Contains(t, m, "response")
|
||||
}
|
||||
|
||||
// grok-build 把 sequence_number 当必填。response.created 从 0 起号,
|
||||
// omitempty 会把 0 整段丢掉,第一帧就反序列化失败。
|
||||
func TestWire_SequenceNumberPresentAtZero(t *testing.T) {
|
||||
created := marshalEvent(t, ResponsesStreamEvent{
|
||||
Type: "response.created",
|
||||
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"},
|
||||
})
|
||||
require.Contains(t, created, "sequence_number")
|
||||
require.EqualValues(t, 0, created["sequence_number"])
|
||||
|
||||
completed := marshalEvent(t, ResponsesStreamEvent{
|
||||
Type: "response.completed",
|
||||
SequenceNumber: 0,
|
||||
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "completed"},
|
||||
})
|
||||
require.Contains(t, completed, "sequence_number")
|
||||
require.EqualValues(t, 0, completed["sequence_number"])
|
||||
|
||||
delta := marshalEvent(t, ResponsesStreamEvent{
|
||||
Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1", Delta: "hi",
|
||||
})
|
||||
require.Contains(t, delta, "sequence_number")
|
||||
require.EqualValues(t, 0, delta["sequence_number"])
|
||||
}
|
||||
|
||||
func TestResponsesOutputUnmarshal_ToolSearchObjectArguments(t *testing.T) {
|
||||
var item ResponsesOutput
|
||||
require.NoError(t, json.Unmarshal([]byte(`{
|
||||
|
||||
@@ -646,6 +646,9 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
|
||||
}
|
||||
|
||||
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
|
||||
// Codex 会把部分内置工具(例如 codex_app 的 automation_update)的 parameters
|
||||
// 根节点声明成对象分支的 oneOf/anyOf,Anthropic 只接受 object 根节点,这里把
|
||||
// 顶层联合摊平成单个 object schema。
|
||||
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
|
||||
const emptyObjectSchema = `{"type":"object","properties":{}}`
|
||||
|
||||
@@ -659,6 +662,8 @@ func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{}}`)
|
||||
}
|
||||
|
||||
flattenAnthropicRootUnions(m)
|
||||
|
||||
typeRaw, ok := m["type"]
|
||||
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
|
||||
m["type"] = json.RawMessage(`"object"`)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// anthropicInputSchemaUnionKeywords 是 Anthropic 在工具 input_schema 顶层拒绝的
|
||||
// JSON Schema 联合关键字;嵌套层不受限制,因此只摊平根节点。
|
||||
var anthropicInputSchemaUnionKeywords = []string{"oneOf", "anyOf", "allOf"}
|
||||
|
||||
// flattenAnthropicRootUnions 用单一 object schema 替换顶层的 oneOf/anyOf/allOf:
|
||||
// 属性取各分支并集,同名属性用嵌套联合保留两侧约束;required 在 oneOf/anyOf
|
||||
// 下取分支交集、在 allOf 下取分支并集。非对象分支无法用 Anthropic 的 object
|
||||
// schema 表达,视为不贡献属性与必填字段。
|
||||
func flattenAnthropicRootUnions(schema map[string]json.RawMessage) {
|
||||
present := make([]string, 0, len(anthropicInputSchemaUnionKeywords))
|
||||
for _, keyword := range anthropicInputSchemaUnionKeywords {
|
||||
if _, ok := schema[keyword]; ok {
|
||||
present = append(present, keyword)
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
properties := anthropicSchemaProperties(schema)
|
||||
rootRequired := anthropicBranchRequired(schema)
|
||||
var (
|
||||
intersections [][]string
|
||||
allOfRequired []string
|
||||
)
|
||||
for _, keyword := range present {
|
||||
raw := schema[keyword]
|
||||
delete(schema, keyword)
|
||||
|
||||
branches, ok := decodeAnthropicUnionBranches(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mergeAnthropicObjectBranchProperties(properties, branches)
|
||||
if keyword == "allOf" {
|
||||
for _, branch := range branches {
|
||||
allOfRequired = appendAnthropicRequired(allOfRequired, anthropicBranchRequired(branch))
|
||||
}
|
||||
continue
|
||||
}
|
||||
intersections = append(intersections, intersectAnthropicRequired(branches))
|
||||
}
|
||||
|
||||
if len(properties) > 0 {
|
||||
if encoded, err := json.Marshal(properties); err == nil {
|
||||
schema["properties"] = encoded
|
||||
}
|
||||
}
|
||||
required := intersectAnthropicRequiredLists(intersections)
|
||||
required = appendAnthropicRequired(required, allOfRequired)
|
||||
required = appendAnthropicRequired(required, rootRequired)
|
||||
if len(required) > 0 {
|
||||
if encoded, err := json.Marshal(required); err == nil {
|
||||
schema["required"] = encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anthropicSchemaProperties(schema map[string]json.RawMessage) map[string]json.RawMessage {
|
||||
properties := make(map[string]json.RawMessage)
|
||||
raw, ok := schema["properties"]
|
||||
if !ok {
|
||||
return properties
|
||||
}
|
||||
if err := json.Unmarshal(raw, &properties); err != nil || properties == nil {
|
||||
return make(map[string]json.RawMessage)
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
// decodeAnthropicUnionBranches 解析联合分支并递归摊平分支自身的顶层联合。
|
||||
// 非对象 JSON 分支以 nil 占位:它不贡献属性,也不要求任何字段。
|
||||
func decodeAnthropicUnionBranches(raw json.RawMessage) ([]map[string]json.RawMessage, bool) {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
branches := make([]map[string]json.RawMessage, 0, len(items))
|
||||
for _, item := range items {
|
||||
var branch map[string]json.RawMessage
|
||||
if err := json.Unmarshal(item, &branch); err != nil || branch == nil {
|
||||
branches = append(branches, nil)
|
||||
continue
|
||||
}
|
||||
flattenAnthropicRootUnions(branch)
|
||||
branches = append(branches, branch)
|
||||
}
|
||||
return branches, true
|
||||
}
|
||||
|
||||
func mergeAnthropicObjectBranchProperties(into map[string]json.RawMessage, branches []map[string]json.RawMessage) {
|
||||
for _, branch := range branches {
|
||||
if !anthropicSchemaBranchIsObject(branch) {
|
||||
continue
|
||||
}
|
||||
raw, ok := branch["properties"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var properties map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &properties); err != nil {
|
||||
continue
|
||||
}
|
||||
for name, property := range properties {
|
||||
existing, seen := into[name]
|
||||
if !seen {
|
||||
into[name] = property
|
||||
continue
|
||||
}
|
||||
if anthropicSchemaJSONEqual(existing, property) {
|
||||
continue
|
||||
}
|
||||
merged, err := json.Marshal(map[string][]json.RawMessage{"anyOf": {existing, property}})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
into[name] = merged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anthropicSchemaBranchIsObject 判断分支是否可能是对象:显式 type=object,或省略
|
||||
// type 但声明了 properties(缺失 type 等价于不约束,不能据此排除对象分支)。
|
||||
func anthropicSchemaBranchIsObject(branch map[string]json.RawMessage) bool {
|
||||
if branch == nil {
|
||||
return false
|
||||
}
|
||||
if raw, ok := branch["type"]; ok {
|
||||
var typ string
|
||||
return json.Unmarshal(raw, &typ) == nil && typ == "object"
|
||||
}
|
||||
_, ok := branch["properties"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func anthropicBranchRequired(branch map[string]json.RawMessage) []string {
|
||||
if branch == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := branch["required"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var required []string
|
||||
if err := json.Unmarshal(raw, &required); err != nil {
|
||||
return nil
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
// intersectAnthropicRequired 返回所有分支共同要求的字段,顺序沿用第一个分支。
|
||||
func intersectAnthropicRequired(branches []map[string]json.RawMessage) []string {
|
||||
lists := make([][]string, 0, len(branches))
|
||||
for _, branch := range branches {
|
||||
lists = append(lists, anthropicBranchRequired(branch))
|
||||
}
|
||||
return intersectAnthropicRequiredLists(lists)
|
||||
}
|
||||
|
||||
func intersectAnthropicRequiredLists(lists [][]string) []string {
|
||||
if len(lists) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := lists[0]
|
||||
for _, list := range lists[1:] {
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
next := make([]string, 0, len(result))
|
||||
for _, name := range result {
|
||||
if containsAnthropicName(list, name) {
|
||||
next = append(next, name)
|
||||
}
|
||||
}
|
||||
result = next
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appendAnthropicRequired(required []string, names []string) []string {
|
||||
merged := make([]string, 0, len(required))
|
||||
merged = append(merged, required...)
|
||||
for _, name := range names {
|
||||
if !containsAnthropicName(merged, name) {
|
||||
merged = append(merged, name)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func containsAnthropicName(names []string, target string) bool {
|
||||
for _, name := range names {
|
||||
if name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func anthropicSchemaJSONEqual(left, right json.RawMessage) bool {
|
||||
var leftBuf, rightBuf bytes.Buffer
|
||||
if json.Compact(&leftBuf, left) != nil || json.Compact(&rightBuf, right) != nil {
|
||||
return false
|
||||
}
|
||||
return leftBuf.String() == rightBuf.String()
|
||||
}
|
||||
@@ -138,3 +138,180 @@ func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
|
||||
assert.Equal(t, "shell", tools[0].Name)
|
||||
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
|
||||
}
|
||||
|
||||
// Codex 的 codex_app 命名空间工具(如 automation_update)把 parameters 根节点声明为
|
||||
// 对象分支的 oneOf/anyOf;Anthropic 拒绝 input_schema 顶层的 oneOf/anyOf/allOf,
|
||||
// 转换时必须摊平成单个 object schema。
|
||||
func TestResponsesToAnthropic_ObjectUnionRootFlattened(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "codex_app__automation_update",
|
||||
Parameters: json.RawMessage(`{
|
||||
"oneOf": [
|
||||
{"type":"object","properties":{"id":{"type":"string"}}},
|
||||
{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}
|
||||
]
|
||||
}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.NotContains(t, schema, "oneOf")
|
||||
assert.NotContains(t, schema, "anyOf")
|
||||
assert.NotContains(t, schema, "allOf")
|
||||
assert.JSONEq(t, `{"id":{"type":"string"}}`, string(schema["properties"]))
|
||||
assert.NotContains(t, schema, "required")
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_ObjectUnionRootMergesBranches(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "codex_app__automation_update",
|
||||
Parameters: json.RawMessage(`{
|
||||
"anyOf": [
|
||||
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
|
||||
{"type":"object","properties":{"mode":{"enum":["update"]},"prompt":{"type":"string"}},"required":["mode","prompt"]}
|
||||
]
|
||||
}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.NotContains(t, schema, "anyOf")
|
||||
|
||||
// 属性取各分支并集;同名属性保留两侧约束(嵌套联合本身是允许的)。
|
||||
var properties map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
|
||||
require.Contains(t, properties, "id")
|
||||
require.Contains(t, properties, "prompt")
|
||||
var mode map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(properties["mode"], &mode))
|
||||
assert.Contains(t, mode, "anyOf")
|
||||
|
||||
// required 取各分支交集,只保留所有分支都要求的字段。
|
||||
assert.JSONEq(t, `["mode"]`, string(schema["required"]))
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_AllOfRootKeepsUnionRequired(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "strict_tool",
|
||||
Parameters: json.RawMessage(`{
|
||||
"allOf": [
|
||||
{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},
|
||||
{"type":"object","properties":{"encoding":{"type":"string"}},"required":["encoding"]}
|
||||
]
|
||||
}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.NotContains(t, schema, "allOf")
|
||||
assert.JSONEq(t, `["path","encoding"]`, string(schema["required"]))
|
||||
}
|
||||
|
||||
// 非对象分支无法用 Anthropic 的 object schema 表达,直接丢弃分支但保留属性词汇。
|
||||
func TestResponsesToAnthropic_RootUnionDropsNonObjectBranches(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "mixed_tool",
|
||||
Parameters: json.RawMessage(`{"oneOf":[{"type":"object","properties":{"path":{"type":"string"}}},{"type":"string"}]}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.NotContains(t, schema, "oneOf")
|
||||
var properties map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
|
||||
assert.Contains(t, properties, "path")
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_NestedUnionPreserved(t *testing.T) {
|
||||
parameters := json.RawMessage(`{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}}}`)
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "get_value",
|
||||
Parameters: parameters,
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
|
||||
}
|
||||
|
||||
// 根节点自带 properties/required 时,与分支约束合并而不是被覆盖。
|
||||
func TestResponsesToAnthropic_RootUnionKeepsRootPropertiesAndRequired(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "merge_root_tool",
|
||||
Parameters: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{"shared":{"type":"string"}},
|
||||
"required":["shared"],
|
||||
"oneOf":[{"type":"object","properties":{"extra":{"type":"integer"}}}]
|
||||
}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.NotContains(t, schema, "oneOf")
|
||||
var properties map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
|
||||
assert.Contains(t, properties, "shared")
|
||||
assert.Contains(t, properties, "extra")
|
||||
assert.JSONEq(t, `["shared"]`, string(schema["required"]))
|
||||
}
|
||||
|
||||
// 端到端:Codex 命名空间工具经降级/摊平后,最终 Anthropic 工具 schema 顶部不再有联合关键字。
|
||||
func TestResponsesToAnthropic_CodexNamespaceRootUnionTool(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "claude-opus-5",
|
||||
"input": "create an automation",
|
||||
"tools": [{
|
||||
"type": "namespace",
|
||||
"name": "codex_app",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "automation_update",
|
||||
"description": "Create, update, view, or delete recurring automations",
|
||||
"parameters": {
|
||||
"oneOf": [
|
||||
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
|
||||
{"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]}
|
||||
]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
|
||||
var requestBody map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &requestBody))
|
||||
|
||||
_, changed, err := AdaptResponsesClientTools(requestBody)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
adapted, err := json.Marshal(requestBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
var responsesReq ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
|
||||
|
||||
anthropicReq, err := ResponsesToAnthropicRequest(&responsesReq)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, anthropicReq.Tools, 1)
|
||||
|
||||
tool := anthropicReq.Tools[0]
|
||||
assert.Equal(t, "codex_app__automation_update", tool.Name)
|
||||
schema := requireObjectInputSchema(t, tool.InputSchema)
|
||||
assert.NotContains(t, schema, "oneOf")
|
||||
assert.NotContains(t, schema, "anyOf")
|
||||
assert.NotContains(t, schema, "allOf")
|
||||
assert.JSONEq(t, `["mode","id"]`, string(schema["required"]))
|
||||
|
||||
wire, err := json.Marshal(tool)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(wire), `"oneOf"`)
|
||||
assert.NotContains(t, string(wire), `"allOf"`)
|
||||
// 同名属性两侧的约束用嵌套联合保留,只允许出现在 properties 内部。
|
||||
assert.Contains(t, string(wire), `"mode":{"anyOf":[{"enum":["view"]},{"enum":["update"]}]}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type responsesToolOutputMedia struct {
|
||||
callID string
|
||||
imageURL string
|
||||
}
|
||||
|
||||
// LiftResponsesToolOutputMedia moves image parts out of Responses tool outputs
|
||||
// and into a following user message. Native Responses endpoints such as
|
||||
// DeepSeek accept function_call_output.output as a string, but Codex view_image
|
||||
// returns an array containing input_image. Keeping the image inside the tool
|
||||
// output makes the upstream report "No tool output found for tool call ...".
|
||||
func LiftResponsesToolOutputMedia(input any) (any, bool) {
|
||||
items, ok := input.([]any)
|
||||
if !ok {
|
||||
return input, false
|
||||
}
|
||||
|
||||
rewritten := make([]any, 0, len(items)+1)
|
||||
changed := false
|
||||
|
||||
// DeepSeek validates parallel tool outputs as one contiguous run. Codex can
|
||||
// inject developer notices between image outputs, so keep those notices
|
||||
// pending until the full batch and its lifted media have been emitted.
|
||||
for index := 0; index < len(items); {
|
||||
item, ok := items[index].(map[string]any)
|
||||
if !ok || !isResponsesToolOutputItem(item) {
|
||||
rewritten = append(rewritten, items[index])
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
batchStart := index
|
||||
outputs := make([]any, 0)
|
||||
trailing := make([]any, 0)
|
||||
pending := make([]responsesToolOutputMedia, 0)
|
||||
batchChanged := false
|
||||
|
||||
for index < len(items) {
|
||||
rawItem := items[index]
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if isResponsesToolOutputItem(item) {
|
||||
rewrittenItem, media, didRewrite := liftResponsesToolOutputMediaItem(item)
|
||||
if didRewrite {
|
||||
batchChanged = true
|
||||
pending = append(pending, media...)
|
||||
}
|
||||
outputs = append(outputs, rewrittenItem)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if isResponsesToolBatchInstruction(item) {
|
||||
trailing = append(trailing, rawItem)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if !batchChanged {
|
||||
rewritten = append(rewritten, items[batchStart:index]...)
|
||||
continue
|
||||
}
|
||||
|
||||
rewritten = append(rewritten, outputs...)
|
||||
if len(pending) > 0 {
|
||||
rewritten = append(rewritten, buildResponsesToolOutputMediaMessage(pending))
|
||||
}
|
||||
rewritten = append(rewritten, trailing...)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return input, false
|
||||
}
|
||||
return rewritten, true
|
||||
}
|
||||
|
||||
func liftResponsesToolOutputMediaItem(item map[string]any) (any, []responsesToolOutputMedia, bool) {
|
||||
output, exists := item["output"]
|
||||
if !exists {
|
||||
return item, nil, false
|
||||
}
|
||||
outputRaw, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return item, nil, false
|
||||
}
|
||||
|
||||
outputText, media, didRewrite := extractToolOutputMedia(outputRaw)
|
||||
if !didRewrite {
|
||||
return item, nil, false
|
||||
}
|
||||
|
||||
item["output"] = outputText
|
||||
callID := strings.TrimSpace(stringValue(item["call_id"]))
|
||||
lifted := make([]responsesToolOutputMedia, 0, len(media))
|
||||
for _, part := range media {
|
||||
if part.ImageURL == nil {
|
||||
continue
|
||||
}
|
||||
imageURL := strings.TrimSpace(part.ImageURL.URL)
|
||||
if imageURL == "" {
|
||||
continue
|
||||
}
|
||||
lifted = append(lifted, responsesToolOutputMedia{
|
||||
callID: callID,
|
||||
imageURL: imageURL,
|
||||
})
|
||||
}
|
||||
return item, lifted, true
|
||||
}
|
||||
|
||||
func buildResponsesToolOutputMediaMessage(pending []responsesToolOutputMedia) map[string]any {
|
||||
content := make([]map[string]any, 0, len(pending)*2)
|
||||
lastCallID := ""
|
||||
for _, media := range pending {
|
||||
if media.callID != lastCallID {
|
||||
text := "Tool output media"
|
||||
if media.callID != "" {
|
||||
text = fmt.Sprintf(toolOutputMediaAttribution, media.callID)
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "input_text",
|
||||
"text": text,
|
||||
})
|
||||
lastCallID = media.callID
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "input_image",
|
||||
"image_url": media.imageURL,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}
|
||||
}
|
||||
|
||||
func isResponsesToolBatchInstruction(item map[string]any) bool {
|
||||
itemType := strings.TrimSpace(stringValue(item["type"]))
|
||||
if itemType != "" && itemType != "message" {
|
||||
return false
|
||||
}
|
||||
role := strings.TrimSpace(stringValue(item["role"]))
|
||||
return role == "developer" || role == "system"
|
||||
}
|
||||
|
||||
func isResponsesToolOutputItem(item map[string]any) bool {
|
||||
switch strings.TrimSpace(stringValue(item["type"])) {
|
||||
case "function_call_output", "custom_tool_call_output",
|
||||
"tool_search_output", "tool_search_call_output", "mcp_tool_call_output":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLiftResponsesToolOutputMedia(t *testing.T) {
|
||||
var input any
|
||||
require.NoError(t, json.Unmarshal([]byte(`[
|
||||
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
|
||||
]`), &input))
|
||||
|
||||
lifted, changed := LiftResponsesToolOutputMedia(input)
|
||||
require.True(t, changed)
|
||||
|
||||
items, ok := lifted.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 3)
|
||||
|
||||
outputItem, ok := items[1].(map[string]any)
|
||||
require.True(t, ok)
|
||||
outputText, ok := outputItem["output"].(string)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, outputText, toolOutputMediaMarker)
|
||||
require.NotContains(t, outputText, "data:image/png")
|
||||
|
||||
mediaMessage, ok := items[2].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "message", mediaMessage["type"])
|
||||
require.Equal(t, "user", mediaMessage["role"])
|
||||
|
||||
parts, ok := mediaMessage["content"].([]map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, parts, 2)
|
||||
require.Equal(t, "input_text", parts[0]["type"])
|
||||
require.Equal(t, "[Tool output media for call call_image]", parts[0]["text"])
|
||||
require.Equal(t, "input_image", parts[1]["type"])
|
||||
require.Equal(t, "data:image/png;base64,AQID", parts[1]["image_url"])
|
||||
}
|
||||
|
||||
func TestLiftResponsesToolOutputMediaLeavesPlainOutputUntouched(t *testing.T) {
|
||||
input := []any{
|
||||
map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_text",
|
||||
"output": "plain output",
|
||||
},
|
||||
}
|
||||
|
||||
lifted, changed := LiftResponsesToolOutputMedia(input)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, input, lifted)
|
||||
}
|
||||
|
||||
func TestLiftResponsesToolOutputMediaKeepsParallelBatchContiguous(t *testing.T) {
|
||||
var input any
|
||||
require.NoError(t, json.Unmarshal([]byte(`[
|
||||
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call","call_id":"call_C","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,QQ=="}]},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized A"}]},
|
||||
{"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":"data:image/png;base64,Qg=="}]},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized B"}]},
|
||||
{"type":"function_call_output","call_id":"call_C","output":[{"type":"input_image","image_url":"data:image/png;base64,Qw=="}]},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized C"}]}
|
||||
]`), &input))
|
||||
|
||||
lifted, changed := LiftResponsesToolOutputMedia(input)
|
||||
require.True(t, changed)
|
||||
|
||||
items, ok := lifted.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 10)
|
||||
|
||||
for index, callID := range []string{"call_A", "call_B", "call_C"} {
|
||||
item, ok := items[index+3].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "function_call_output", item["type"])
|
||||
require.Equal(t, callID, item["call_id"])
|
||||
outputText, ok := item["output"].(string)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, outputText, toolOutputMediaMarker)
|
||||
require.NotContains(t, outputText, "data:image/png")
|
||||
}
|
||||
|
||||
mediaMessage, ok := items[6].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "message", mediaMessage["type"])
|
||||
require.Equal(t, "user", mediaMessage["role"])
|
||||
parts, ok := mediaMessage["content"].([]map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, parts, 6)
|
||||
require.Equal(t, "[Tool output media for call call_A]", parts[0]["text"])
|
||||
require.Equal(t, "data:image/png;base64,QQ==", parts[1]["image_url"])
|
||||
require.Equal(t, "[Tool output media for call call_B]", parts[2]["text"])
|
||||
require.Equal(t, "data:image/png;base64,Qg==", parts[3]["image_url"])
|
||||
require.Equal(t, "[Tool output media for call call_C]", parts[4]["text"])
|
||||
require.Equal(t, "data:image/png;base64,Qw==", parts[5]["image_url"])
|
||||
|
||||
for index, text := range []string{"resized A", "resized B", "resized C"} {
|
||||
notice, ok := items[index+7].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "developer", notice["role"])
|
||||
content, ok := notice["content"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, content, 1)
|
||||
contentPart, ok := content[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, text, contentPart["text"])
|
||||
}
|
||||
}
|
||||
@@ -642,8 +642,11 @@ type ResponsesStreamEvent struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Param string `json:"param,omitempty"`
|
||||
|
||||
// Sequence number for ordering events
|
||||
SequenceNumber int `json:"sequence_number,omitempty"`
|
||||
// SequenceNumber orders streamed events. Strict Responses clients (Grok Build,
|
||||
// Codex CLI) declare it required and abort with `missing field 'sequence_number'`
|
||||
// when it is absent, so it is always emitted — no omitempty. Same rule as
|
||||
// ResponsesResponse.CreatedAt. Zero is a valid first-event value.
|
||||
SequenceNumber int `json:"sequence_number"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1211,11 +1211,20 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o
|
||||
// NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数
|
||||
// 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token
|
||||
// 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。
|
||||
//
|
||||
// Deliberately NO `schedulable = TRUE` filter here: paused accounts
|
||||
// (schedulable=false, status=active) still hold valid refresh tokens and
|
||||
// their stored access_token must keep working for the admin usage-window
|
||||
// probe. Excluding them lets the token silently expire, after which the
|
||||
// dashboard reports a false "needs re-auth" even though Test Connection
|
||||
// (which refreshes on demand) succeeds. Permanent rejection is already
|
||||
// covered by the status = 'active' filter (error accounts drop out), and
|
||||
// accounts whose refresh actually fails are rate-limited by the
|
||||
// ExcludeRetryCooldown clause below.
|
||||
query := `
|
||||
SELECT id
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND schedulable = TRUE
|
||||
AND platform = ANY($1)
|
||||
AND id > $2`
|
||||
if options.ActiveOnly {
|
||||
|
||||
@@ -327,14 +327,16 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
|
||||
"expires_at": now.Add(30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "grok-oauth-unschedulable-excluded",
|
||||
// Paused but active OAuth accounts (schedulable=false) must remain refresh
|
||||
// candidates so their stored access_token does not silently expire.
|
||||
paused := mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "grok-oauth-paused-included",
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{"refresh_token": "refresh-unschedulable"},
|
||||
Credentials: map[string]any{"refresh_token": "refresh-paused"},
|
||||
})
|
||||
s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx))
|
||||
s.Require().NoError(s.client.Account.UpdateOneID(paused.ID).SetSchedulable(false).Exec(s.ctx))
|
||||
mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "grok-api-key-excluded",
|
||||
Platform: service.PlatformGrok,
|
||||
@@ -393,15 +395,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
|
||||
s.Require().NoError(err)
|
||||
first := firstPage.Accounts
|
||||
s.Require().Len(first, 2)
|
||||
s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID})
|
||||
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID)
|
||||
s.Require().Equal([]int64{valid1.ID, paused.ID}, []int64{first[0].ID, first[1].ID})
|
||||
|
||||
options.AfterID = first[len(first)-1].ID
|
||||
secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options)
|
||||
s.Require().NoError(err)
|
||||
second := secondPage.Accounts
|
||||
s.Require().Len(second, 1)
|
||||
s.Require().Equal(valid3.ID, second[0].ID)
|
||||
s.Require().Len(second, 2)
|
||||
s.Require().Equal([]int64{valid2.ID, valid3.ID}, []int64{second[0].ID, second[1].ID})
|
||||
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, second[0].ID)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,8 +288,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T)
|
||||
|
||||
normalized := normalizeSQLWhitespace(capturedSQL)
|
||||
require.Contains(t, normalized, "deleted_at IS NULL")
|
||||
require.Contains(t, normalized, "schedulable = TRUE",
|
||||
"permanently unschedulable accounts must not remain OAuth refresh candidates")
|
||||
require.NotContains(t, normalized, "schedulable = TRUE",
|
||||
"paused (schedulable=false, status=active) accounts must remain OAuth refresh candidates: excluding them lets their stored access_token expire and the usage-window probe then reports a false 'needs re-auth'; permanent rejection is already covered by status = 'active' and refresh failures are bounded by the retry-cooldown exclusion")
|
||||
require.Contains(t, normalized, "status = 'active'")
|
||||
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
|
||||
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
|
||||
|
||||
@@ -10,6 +10,62 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// groupUsageRollupSnapshot 是一次汇总水位的快照。
|
||||
// closedBefore / retainedFrom 在水位无效时退化为 1970-01-01,
|
||||
// 与此前 SQL 里 CASE WHEN valid 的语义一一对应。
|
||||
type groupUsageRollupSnapshot struct {
|
||||
valid bool
|
||||
closedBefore string // date,历史日桶的右开边界
|
||||
retainedDate string // retained_from 在服务端时区内的日期,历史日桶的左闭边界
|
||||
tailStart time.Time // 尾段(还没进日桶的那部分 usage_logs)的起点
|
||||
}
|
||||
|
||||
// readGroupUsageRollupSnapshot 读一次汇总水位并在 Go 侧判定有效性。
|
||||
//
|
||||
// 判定条件与原先 SQL 里的 state_values CTE 完全一致:
|
||||
// 恰好一行、时区名与当前服务端配置相同、且水位不在未来。
|
||||
func (r *usageLogRepository) readGroupUsageRollupSnapshot(ctx context.Context, timezoneName, todayDate string) (groupUsageRollupSnapshot, error) {
|
||||
epoch := time.Unix(0, 0).UTC()
|
||||
invalid := groupUsageRollupSnapshot{
|
||||
closedBefore: "1970-01-01",
|
||||
retainedDate: "1970-01-01",
|
||||
tailStart: epoch,
|
||||
}
|
||||
|
||||
var rowCount int
|
||||
var closedBefore sql.NullString
|
||||
var retainedFrom sql.NullTime
|
||||
var stateTimezone sql.NullString
|
||||
if err := scanSingleRow(ctx, r.sql, `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
MAX(closed_before)::text,
|
||||
MAX(retained_from),
|
||||
MAX(timezone_name)
|
||||
FROM usage_group_rollup_state
|
||||
WHERE id = 1
|
||||
`, nil, &rowCount, &closedBefore, &retainedFrom, &stateTimezone); err != nil {
|
||||
return groupUsageRollupSnapshot{}, fmt.Errorf("读取分组用量汇总水位: %w", err)
|
||||
}
|
||||
if rowCount != 1 || !closedBefore.Valid || !retainedFrom.Valid ||
|
||||
!stateTimezone.Valid || stateTimezone.String != timezoneName ||
|
||||
closedBefore.String > todayDate {
|
||||
return invalid, nil
|
||||
}
|
||||
|
||||
tailStart, err := service.ParseGroupUsageDate(closedBefore.String)
|
||||
if err != nil {
|
||||
// 水位值解析不出来,按无效处理:宁可多扫一次,也不要算错钱。
|
||||
return invalid, nil //nolint:nilerr // 与上面的 valid 判定同语义,均降级为全量重算
|
||||
}
|
||||
return groupUsageRollupSnapshot{
|
||||
valid: true,
|
||||
closedBefore: closedBefore.String,
|
||||
retainedDate: service.GroupUsageDate(retainedFrom.Time),
|
||||
tailStart: tailStart.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Context, todayStart time.Time) (results []usagestats.GroupUsageSummary, err error) {
|
||||
todayStart = service.GroupUsageTodayStart(todayStart)
|
||||
yesterdayStart := service.GroupUsageYesterdayStart(todayStart)
|
||||
@@ -17,40 +73,34 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
|
||||
todayDate := service.GroupUsageDate(todayStart)
|
||||
yesterdayDate := service.GroupUsageDate(yesterdayStart)
|
||||
|
||||
state, err := r.readGroupUsageRollupSnapshot(ctx, timezoneName, todayDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 尾段起点必须以**查询参数**的形式给进来。
|
||||
//
|
||||
// 此前它是在同一条 SQL 里由 state CTE 算出、再 CROSS JOIN 给 tail 用的,
|
||||
// 于是 created_at 的下界对 planner 来说是个运行期才知道的值:既进不了
|
||||
// index cond,也估不准选择性,只能退化成 usage_logs 全表扫。
|
||||
// 生产实测(1721 万行 / PostgreSQL 17):
|
||||
// Seq Scan on usage_logs … rows=17212870, Rows Removed by Join Filter: 17178082
|
||||
// Execution Time: 48720 ms
|
||||
// 换成参数之后走 idx_usage_logs_created_at:
|
||||
// Index Scan … Index Cond: (created_at >= $7)
|
||||
// Execution Time: 34 ms
|
||||
const query = `
|
||||
WITH state_values AS (
|
||||
SELECT
|
||||
COUNT(*) = 1
|
||||
AND MAX(timezone_name) = $3
|
||||
AND MAX(closed_before) <= $4::date AS valid,
|
||||
MAX(closed_before) AS closed_before,
|
||||
MAX(retained_from) AS retained_from
|
||||
FROM usage_group_rollup_state
|
||||
WHERE id = 1
|
||||
),
|
||||
state AS (
|
||||
SELECT
|
||||
CASE WHEN valid THEN closed_before ELSE DATE '1970-01-01' END AS closed_before,
|
||||
CASE WHEN valid THEN retained_from ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' END AS retained_from,
|
||||
CASE
|
||||
WHEN valid THEN closed_before::timestamp AT TIME ZONE $3::text
|
||||
ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
||||
END AS tail_start,
|
||||
valid
|
||||
FROM state_values
|
||||
),
|
||||
historical AS (
|
||||
WITH historical AS (
|
||||
SELECT
|
||||
rollup.group_id,
|
||||
COALESCE(SUM(rollup.actual_cost), 0) AS actual_cost,
|
||||
COALESCE(SUM(rollup.actual_cost) FILTER (
|
||||
WHERE rollup.bucket_date = $5::date
|
||||
WHERE rollup.bucket_date = $3::date
|
||||
), 0) AS yesterday_cost
|
||||
FROM usage_group_daily_rollups rollup
|
||||
CROSS JOIN state
|
||||
WHERE state.valid
|
||||
AND rollup.bucket_date >= (state.retained_from AT TIME ZONE $3::text)::date
|
||||
AND rollup.bucket_date < state.closed_before
|
||||
WHERE $4::boolean
|
||||
AND rollup.bucket_date >= $5::date
|
||||
AND rollup.bucket_date < $6::date
|
||||
GROUP BY rollup.group_id
|
||||
),
|
||||
tail AS (
|
||||
@@ -63,8 +113,7 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
|
||||
AND ul.created_at < $1
|
||||
), 0) AS yesterday_cost
|
||||
FROM usage_logs ul
|
||||
CROSS JOIN state
|
||||
WHERE ul.created_at >= state.tail_start
|
||||
WHERE ul.created_at >= $7
|
||||
GROUP BY ul.group_id
|
||||
)
|
||||
SELECT
|
||||
@@ -83,9 +132,11 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
|
||||
query,
|
||||
todayStart,
|
||||
yesterdayStart,
|
||||
timezoneName,
|
||||
todayDate,
|
||||
yesterdayDate,
|
||||
state.valid,
|
||||
state.retainedDate,
|
||||
state.closedBefore,
|
||||
state.tailStart,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// pluginKVKeyPrefix 是所有插件通用键值存储在 Redis 中的根前缀。版本段 v1 便于未来
|
||||
// 变更内部键结构时平滑迁移。
|
||||
const pluginKVKeyPrefix = "plugin:kv:v1:"
|
||||
|
||||
// pluginKVScanCount 是 SCAN 每轮的 COUNT 提示,兼顾单轮开销与往返次数。
|
||||
const pluginKVScanCount = 200
|
||||
|
||||
type pluginKVStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
// NewPluginKVStore 返回 Redis 支撑的通用插件键值存储。使用 Redis 而非进程内存,使得
|
||||
// 插件状态可跨副本、跨重启共享,这正是有状态插件所需的通用能力。
|
||||
func NewPluginKVStore(rdb *redis.Client) service.PluginKVStore {
|
||||
return &pluginKVStore{rdb: rdb}
|
||||
}
|
||||
|
||||
// namespacePrefix 拼出某插件某命名空间的内部键前缀。pluginKey / namespace 均已由宿主
|
||||
// 服务端按 [A-Za-z0-9._-] 校验,不含 ':' 或 glob 元字符,键结构因此无歧义。
|
||||
func (s *pluginKVStore) namespacePrefix(pluginKey, namespace string) string {
|
||||
return pluginKVKeyPrefix + pluginKey + ":" + namespace + ":"
|
||||
}
|
||||
|
||||
func (s *pluginKVStore) fullKey(pluginKey, namespace, key string) string {
|
||||
return s.namespacePrefix(pluginKey, namespace) + key
|
||||
}
|
||||
|
||||
func (s *pluginKVStore) Get(ctx context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
|
||||
value, err := s.rdb.Get(ctx, s.fullKey(pluginKey, namespace, key)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("get plugin kv: %w", err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func (s *pluginKVStore) Set(ctx context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
|
||||
// go-redis 将 ttl==0 视为不过期,正好对应“持久保存”语义。
|
||||
if err := s.rdb.Set(ctx, s.fullKey(pluginKey, namespace, key), value, ttl).Err(); err != nil {
|
||||
return fmt.Errorf("set plugin kv: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pluginKVStore) Delete(ctx context.Context, pluginKey, namespace, key string) error {
|
||||
if err := s.rdb.Del(ctx, s.fullKey(pluginKey, namespace, key)).Err(); err != nil {
|
||||
return fmt.Errorf("delete plugin kv: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pluginKVStore) List(ctx context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
internalPrefix := s.namespacePrefix(pluginKey, namespace)
|
||||
// keyPrefix 已按 [A-Za-z0-9._-] 校验,不含 glob 元字符,可安全拼接进 MATCH。
|
||||
pattern := internalPrefix + keyPrefix + "*"
|
||||
seen := make(map[string]struct{}, limit)
|
||||
keys := make([]string, 0, limit)
|
||||
var cursor uint64
|
||||
for {
|
||||
batch, next, err := s.rdb.Scan(ctx, cursor, pattern, pluginKVScanCount).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan plugin kv: %w", err)
|
||||
}
|
||||
for _, full := range batch {
|
||||
// SCAN 可能返回重复键,去重后再裁剪前缀返回面向插件的键名。
|
||||
if _, ok := seen[full]; ok {
|
||||
continue
|
||||
}
|
||||
seen[full] = struct{}{}
|
||||
keys = append(keys, strings.TrimPrefix(full, internalPrefix))
|
||||
if len(keys) >= limit {
|
||||
return keys, nil
|
||||
}
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestPluginKVStore(t *testing.T) (*pluginKVStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
store, ok := NewPluginKVStore(rdb).(*pluginKVStore)
|
||||
require.True(t, ok)
|
||||
return store, mr
|
||||
}
|
||||
|
||||
func TestPluginKVStore_SetGetDelete(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
value, found, err := store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, value)
|
||||
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("hello"), 0))
|
||||
value, found, err = store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, []byte("hello"), value)
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "p1", "state", "k"))
|
||||
_, found, err = store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestPluginKVStore_TTLExpires(t *testing.T) {
|
||||
store, mr := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v"), 30*time.Second))
|
||||
full := store.fullKey("p1", "state", "k")
|
||||
ttl := mr.TTL(full)
|
||||
assert.Equal(t, 30*time.Second, ttl)
|
||||
|
||||
mr.FastForward(31 * time.Second)
|
||||
_, found, err := store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestPluginKVStore_PluginIsolation(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "shared", []byte("one"), 0))
|
||||
require.NoError(t, store.Set(ctx, "p2", "state", "shared", []byte("two"), 0))
|
||||
|
||||
v1, found, err := store.Get(ctx, "p1", "state", "shared")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, []byte("one"), v1)
|
||||
|
||||
v2, found, err := store.Get(ctx, "p2", "state", "shared")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, []byte("two"), v2)
|
||||
}
|
||||
|
||||
func TestPluginKVStore_ListStripsPrefixAndHonorsLimit(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
for _, key := range []string{"account-1", "account-2", "account-3", "other"} {
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", key, []byte("x"), 0))
|
||||
}
|
||||
// 另一命名空间/插件的键不应混入结果。
|
||||
require.NoError(t, store.Set(ctx, "p1", "other-ns", "account-9", []byte("x"), 0))
|
||||
require.NoError(t, store.Set(ctx, "p2", "state", "account-9", []byte("x"), 0))
|
||||
|
||||
keys, err := store.List(ctx, "p1", "state", "account-", 100)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3"}, keys)
|
||||
|
||||
keys, err = store.List(ctx, "p1", "state", "", 100)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3", "other"}, keys)
|
||||
|
||||
keys, err = store.List(ctx, "p1", "state", "account-", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, keys, 2)
|
||||
|
||||
keys, err = store.List(ctx, "p1", "state", "account-", 0)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, keys)
|
||||
}
|
||||
|
||||
// namespace 是另一 namespace 的前缀时(state vs state2),List 不能串味——内部键的 ':'
|
||||
// 分隔符 + namespace 字符集排除 ':' 共同保证消歧。
|
||||
func TestPluginKVStore_ListNamespacePrefixDisambiguation(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k1", []byte("a"), 0))
|
||||
require.NoError(t, store.Set(ctx, "p1", "state2", "k2", []byte("b"), 0))
|
||||
|
||||
keys, err := store.List(ctx, "p1", "state", "", 100)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"k1"}, keys)
|
||||
|
||||
keys, err = store.List(ctx, "p1", "state2", "", 100)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"k2"}, keys)
|
||||
}
|
||||
|
||||
func TestPluginKVStore_OverwriteResetsValueAndTTL(t *testing.T) {
|
||||
store, mr := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v1"), 10*time.Second))
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v2"), 0))
|
||||
|
||||
value, found, err := store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, []byte("v2"), value)
|
||||
// ttl=0 覆盖后应变为不过期。
|
||||
assert.Equal(t, time.Duration(0), mr.TTL(store.fullKey("p1", "state", "k")))
|
||||
}
|
||||
|
||||
func TestPluginKVStore_EmptyValueIsFound(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte{}, 0))
|
||||
value, found, err := store.Get(ctx, "p1", "state", "k")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Empty(t, value)
|
||||
}
|
||||
|
||||
func TestPluginKVStore_DeleteMissingIsNoError(t *testing.T) {
|
||||
store, _ := newTestPluginKVStore(t)
|
||||
require.NoError(t, store.Delete(context.Background(), "p1", "state", "missing"))
|
||||
}
|
||||
@@ -347,7 +347,7 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
|
||||
codes, err := r.client.RedeemCode.Query().
|
||||
Where(redeemcode.UsedByEQ(userID)).
|
||||
WithGroup().
|
||||
Order(dbent.Desc(redeemcode.FieldUsedAt)).
|
||||
Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
|
||||
Limit(limit).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
@@ -377,7 +377,7 @@ func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID i
|
||||
WithGroup().
|
||||
Offset(params.Offset()).
|
||||
Limit(params.Limit()).
|
||||
Order(dbent.Desc(redeemcode.FieldUsedAt)).
|
||||
Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
|
||||
@@ -22,3 +24,50 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
|
||||
s.Require().Equal("VALUE-10", codes[0].Code)
|
||||
s.Require().Equal("VALUE-20", codes[1].Code)
|
||||
}
|
||||
|
||||
func (s *RedeemCodeRepoSuite) TestHistoryPaginationIsolationAndOrdering() {
|
||||
user := s.createUser("history@example.com")
|
||||
other := s.createUser("other-history@example.com")
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
var ids []int64
|
||||
types := []string{service.RedeemTypeBalance, service.RedeemTypeConcurrency, service.RedeemTypeSubscription, "admin_balance", "admin_concurrency"}
|
||||
for i := 0; i < 105; i++ {
|
||||
usedAt := now
|
||||
if i == 0 {
|
||||
usedAt = now.Add(time.Hour)
|
||||
}
|
||||
code, err := s.client.RedeemCode.Create().SetCode(fmt.Sprintf("HISTORY-%d", i)).SetType(types[i%len(types)]).SetStatus(service.StatusUsed).SetUsedBy(user.ID).SetUsedAt(usedAt).Save(s.ctx)
|
||||
s.Require().NoError(err)
|
||||
ids = append(ids, code.ID)
|
||||
}
|
||||
_, err := s.client.RedeemCode.Create().SetCode("OTHER-HISTORY").SetType(service.RedeemTypeSubscription).SetStatus(service.StatusUsed).SetUsedBy(other.ID).SetUsedAt(now.Add(2 * time.Hour)).Save(s.ctx)
|
||||
s.Require().NoError(err)
|
||||
expected := []int64{ids[0]}
|
||||
for i := len(ids) - 1; i > 0; i-- {
|
||||
expected = append(expected, ids[i])
|
||||
}
|
||||
for _, size := range []int{20, 50, 100} {
|
||||
var got []int64
|
||||
for page := 1; page <= (105+size-1)/size+1; page++ {
|
||||
codes, result, err := s.repo.ListByUserPaginated(s.ctx, user.ID, pagination.PaginationParams{Page: page, PageSize: size}, "")
|
||||
s.Require().NoError(err)
|
||||
s.Equal(int64(105), result.Total)
|
||||
s.LessOrEqual(len(codes), size)
|
||||
for _, code := range codes {
|
||||
s.Equal(user.ID, *code.UsedBy)
|
||||
got = append(got, code.ID)
|
||||
}
|
||||
}
|
||||
s.Equal(expected, got)
|
||||
}
|
||||
codes, result, err := s.repo.ListByUserPaginated(s.ctx, other.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
|
||||
s.Require().NoError(err)
|
||||
s.Equal(int64(1), result.Total)
|
||||
s.Require().Len(codes, 1)
|
||||
s.Equal("OTHER-HISTORY", codes[0].Code)
|
||||
empty := s.createUser("empty-history@example.com")
|
||||
codes, result, err = s.repo.ListByUserPaginated(s.ctx, empty.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
|
||||
s.Require().NoError(err)
|
||||
s.Empty(codes)
|
||||
s.Zero(result.Total)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,23 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) {
|
||||
todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
|
||||
yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery(`(?s)usage_group_rollup_state.*usage_group_daily_rollups.*created_at >= state\.tail_start`).
|
||||
WithArgs(todayStart, yesterdayStart, "America/New_York", "2026-03-09", "2026-03-08").
|
||||
// 水位有效:closed_before = 2026-03-07(≤ today),时区与配置一致。
|
||||
mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
|
||||
AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York"))
|
||||
|
||||
// 尾段起点必须以参数形式进 WHERE —— 这正是把 usage_logs 全表扫换回
|
||||
// idx_usage_logs_created_at 索引扫的那一步。
|
||||
mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`).
|
||||
WithArgs(
|
||||
todayStart,
|
||||
yesterdayStart,
|
||||
"2026-03-08", // yesterdayDate
|
||||
true, // 水位有效
|
||||
"2026-02-01", // retained_from 在配置时区内的日期
|
||||
"2026-03-07", // closed_before
|
||||
time.Date(2026, 3, 7, 5, 0, 0, 0, time.UTC), // tail_start = closed_before 在配置时区的零点
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}).
|
||||
AddRow(int64(7), 12.5, 1.25, 2.5))
|
||||
|
||||
@@ -31,3 +46,51 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) {
|
||||
require.InDelta(t, 2.5, result[0].YesterdayCost, 0.0000001)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// 水位无效(时区变了 / 水位在未来 / 行缺失)时必须退化成"从 epoch 起全量重算",
|
||||
// 与改动前 SQL 里 CASE WHEN valid 的 ELSE 分支逐字对应:
|
||||
// 历史日桶整段不参与,尾段起点回到 1970-01-01。
|
||||
func TestUsageLogRepositoryGetAllGroupUsageSummaryFallsBackWhenWatermarkInvalid(t *testing.T) {
|
||||
epoch := time.Unix(0, 0).UTC()
|
||||
todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
|
||||
yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rows *sqlmock.Rows
|
||||
}{
|
||||
{
|
||||
name: "水位行缺失",
|
||||
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
|
||||
AddRow(0, nil, nil, nil),
|
||||
},
|
||||
{
|
||||
name: "时区与当前配置不一致",
|
||||
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
|
||||
AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "Asia/Shanghai"),
|
||||
},
|
||||
{
|
||||
name: "水位位于未来",
|
||||
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
|
||||
AddRow(1, "2026-03-10", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := newUsageLogRepositoryWithSQL(nil, db)
|
||||
useGroupUsageRepositoryTestTimezone(t, "America/New_York")
|
||||
|
||||
mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`).
|
||||
WillReturnRows(tc.rows)
|
||||
mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`).
|
||||
WithArgs(todayStart, yesterdayStart, "2026-03-08", false, "1970-01-01", "1970-01-01", epoch).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}))
|
||||
|
||||
_, err := repo.GetAllGroupUsageSummary(context.Background(), todayStart)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewErrorPassthroughRepository,
|
||||
NewTLSFingerprintProfileRepository,
|
||||
NewPluginRepository,
|
||||
NewPluginKVStore,
|
||||
NewChannelRepository,
|
||||
NewChannelMonitorRepository,
|
||||
NewChannelMonitorV2Repository,
|
||||
|
||||
@@ -759,6 +759,7 @@ func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut
|
||||
plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable)
|
||||
plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete)
|
||||
plugins.GET("/:id/config", h.Admin.Plugin.GetConfig)
|
||||
plugins.GET("/:id/status", h.Admin.Plugin.Status)
|
||||
plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig)
|
||||
plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test)
|
||||
plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession)
|
||||
|
||||
@@ -368,6 +368,11 @@ func RegisterGatewayRoutes(
|
||||
rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) {
|
||||
r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)
|
||||
}
|
||||
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
|
||||
rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
||||
rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
||||
rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
||||
}
|
||||
rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler)
|
||||
rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler))
|
||||
rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
||||
|
||||
@@ -157,6 +157,10 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) {
|
||||
{http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
|
||||
{http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`},
|
||||
{http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`},
|
||||
{http.MethodPost, "/api/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
|
||||
{http.MethodPost, "/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
|
||||
{http.MethodPost, "/v1/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
|
||||
{http.MethodPost, "/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
|
||||
{http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
|
||||
{http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`},
|
||||
{http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSeedanceNativeRoutes(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter()
|
||||
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
|
||||
for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} {
|
||||
path := prefix + "/contents/generations/tasks"
|
||||
if method != http.MethodPost {
|
||||
path += "/task-1"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(`{"model":"seedance","content":[{"type":"text","text":"waves"}]}`)))
|
||||
require.NotEqual(t, http.StatusNotFound, w.Code, method+" "+path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedanceRejectsOtherPlatforms(t *testing.T) {
|
||||
for _, platform := range []string{service.PlatformGrok, service.PlatformAnthropic, service.PlatformGemini} {
|
||||
w := httptest.NewRecorder()
|
||||
newGatewayRoutesTestRouter(platform).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{"model":"seedance","content":[{}]}`)))
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
|
||||
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
|
||||
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
|
||||
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
|
||||
//
|
||||
// 例外:DeepSeek 平台的空映射不再是「允许所有」,改按官方模型白名单判定
|
||||
// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发
|
||||
// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。
|
||||
func (a *Account) IsModelSupported(requestedModel string) bool {
|
||||
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
|
||||
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
|
||||
@@ -861,6 +865,9 @@ func (a *Account) IsModelSupported(requestedModel string) bool {
|
||||
if a.IsOpenAIOAuth() {
|
||||
return isOpenAIOAuthServableModel(requestedModel)
|
||||
}
|
||||
if a.Platform == PlatformDeepseek {
|
||||
return isDeepseekServableModel(requestedModel)
|
||||
}
|
||||
return true // 无映射 = 允许所有
|
||||
}
|
||||
if mappingSupportsRequestedModel(mapping, requestedModel) {
|
||||
@@ -1821,6 +1828,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if capability == OpenAIEndpointCapabilitySeedance {
|
||||
configured, _ := a.openAIEndpointCapabilitySet()
|
||||
return configured["seedance"] && a.Platform == PlatformOpenAI && a.Type == AccountTypeAPIKey &&
|
||||
strings.TrimSpace(a.GetCredential("base_url")) != ""
|
||||
}
|
||||
if capability == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -231,6 +231,82 @@ func TestAccountIsModelSupported(t *testing.T) {
|
||||
requestedModel: "gemini-3-flash",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
// DeepSeek 空映射:官方模型白名单(不再是「允许所有」)
|
||||
{
|
||||
name: "deepseek empty mapping allows official flash",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-flash",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping normalizes claude code long context suffix",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-flash[1m]",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping matches case-insensitively",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-FLASH",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping allows versioned pro name",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-v4-pro-0813",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping rejects retired chat model",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-chat",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping rejects foreign claude model",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "claude-sonnet-4-6",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping rejects unknown gpt model",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "gpt-future-model",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "deepseek empty mapping rejects typo",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "deepseek-flas",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "deepseek explicit mapping wins over whitelist",
|
||||
platform: PlatformDeepseek,
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"foo-bar": "deepseek-flash",
|
||||
},
|
||||
},
|
||||
requestedModel: "foo-bar",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non deepseek empty mapping still allows all",
|
||||
platform: PlatformAnthropic,
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "any-model",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -86,7 +86,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
||||
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
|
||||
}
|
||||
|
||||
mappedModel := s.getMappedModel(account, originalModel)
|
||||
// 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体;
|
||||
// 裸名有显式映射时保持原行为。
|
||||
mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body)
|
||||
if !variantResolved {
|
||||
mappedModel = s.getMappedModel(account, originalModel)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel)
|
||||
}
|
||||
if mappedModel == "" {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
|
||||
|
||||
@@ -1387,6 +1387,49 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
|
||||
|
||||
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
|
||||
// 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
|
||||
// 回归:上游事件之间的空分隔行不能再透传,否则下游看到的是 "data: ...\n\n\n"。
|
||||
// google-genai Go SDK 按 "\n\n" 切事件,多出的 "\n" 会让第二个事件的前缀变成
|
||||
// "\ndata" 而报 invalid stream chunk(Antigravity CLI 每条流都在第二个事件中断)。
|
||||
func TestHandleGeminiStreamingResponse_EventSeparatorIsExactlyOneBlankLine(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newAntigravityTestService(&config.Config{
|
||||
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
|
||||
|
||||
first := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}`
|
||||
second := `{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":5}}`
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
// 上游原样:每个 data 行后跟一个空分隔行(第二个事件用 CRLF,覆盖两种换行)
|
||||
fmt.Fprintf(pw, "data: %s\n\n", first)
|
||||
fmt.Fprintf(pw, "data: %s\r\n\r\n", second)
|
||||
}()
|
||||
|
||||
result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
|
||||
_ = pr.Close()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
body := rec.Body.String()
|
||||
require.Equal(t, "data: "+first+"\n\ndata: "+second+"\n\n", body)
|
||||
require.NotContains(t, body, "\n\n\n", "events must be separated by exactly one blank line")
|
||||
|
||||
// 模拟 google-genai 的切帧方式:按 "\n\n" 切,每个非空 token 都必须以 "data" 为前缀
|
||||
for _, token := range strings.Split(strings.TrimSuffix(body, "\n\n"), "\n\n") {
|
||||
prefix, _, _ := strings.Cut(token, ":")
|
||||
require.Equal(t, "data", prefix, "token %q would be rejected by a \\n\\n-delimited SSE parser", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newAntigravityTestService(&config.Config{
|
||||
|
||||
@@ -197,6 +197,11 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 {
|
||||
keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
|
||||
}
|
||||
// go-genai / python-genai 不会忽略 SSE 注释行,收到 ":\n\n" 会直接把整个流判成
|
||||
// invalid stream chunk 而中断(Antigravity CLI 在用 go-genai)。对这类客户端宁可不发心跳。
|
||||
if keepaliveInterval > 0 && downstreamRejectsSSEComments(c) {
|
||||
keepaliveInterval = 0
|
||||
}
|
||||
var keepaliveTicker *time.Ticker
|
||||
if keepaliveInterval > 0 {
|
||||
keepaliveTicker = time.NewTicker(keepaliveInterval)
|
||||
@@ -288,6 +293,14 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
||||
continue
|
||||
}
|
||||
|
||||
// 上游每个 data 事件后面跟一个空行作为事件分隔。上面已经把 data 行写成
|
||||
// "data: ...\n\n",若再把这个空行透传出去,事件之间就会变成 "\n\n\n"。
|
||||
// google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,多出的
|
||||
// "\n" 会粘到下一个事件开头,前缀变成 "\ndata" 而被判成 invalid stream chunk。
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cw.Fprintf("%s\n", line)
|
||||
|
||||
case <-intervalCh:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时,
|
||||
// 客户端(如 Antigravity CLI / go-genai)习惯发"裸"模型名(gemini-3.8-flash)并用
|
||||
// generationConfig.thinkingConfig 表达思考深度;而 Antigravity 上游的模型目录只有
|
||||
// gemini-3.8-flash-low / -medium / -high / -tiered 这类带后缀的变体,裸名直接转发会被上游
|
||||
// 以 404 "Requested entity was not found." 拒绝。
|
||||
//
|
||||
// 这里在账号 model_mapping 没有为裸名配置显式条目、但配置了它的后缀变体时,
|
||||
// 按 thinkingConfig 自动挑一个变体:
|
||||
// - thinkingLevel: "low" / "medium" / "high" → 同名后缀
|
||||
// - thinkingBudget: -1(动态)→ high;0 → low;1..1024 → low;1025..8192 → medium;>8192 → high
|
||||
// - 未携带 thinkingConfig → high(与 Gemini 3 系列默认开启动态思考一致)
|
||||
// 选中的后缀在映射表里不存在时按 high → medium → low → tiered 的顺序降级到存在的变体。
|
||||
// 显式映射(裸名本身在 model_mapping 里)始终优先,行为与现在完全一致。
|
||||
|
||||
var geminiThinkingVariantSuffixes = []string{"-low", "-medium", "-high", "-tiered"}
|
||||
|
||||
const (
|
||||
geminiThinkingBudgetLowMax = 1024
|
||||
geminiThinkingBudgetMediumMax = 8192
|
||||
)
|
||||
|
||||
type geminiThinkingConfigProbe struct {
|
||||
GenerationConfig struct {
|
||||
ThinkingConfig *struct {
|
||||
ThinkingBudget *json.Number `json:"thinkingBudget"`
|
||||
ThinkingLevel string `json:"thinkingLevel"`
|
||||
} `json:"thinkingConfig"`
|
||||
} `json:"generationConfig"`
|
||||
}
|
||||
|
||||
// hasGeminiThinkingVariantSuffix 判断模型名是否已经带了思考深度后缀。
|
||||
func hasGeminiThinkingVariantSuffix(model string) bool {
|
||||
for _, suffix := range geminiThinkingVariantSuffixes {
|
||||
if strings.HasSuffix(model, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// geminiThinkingLevelFromBody 从 Gemini 原生请求体推导期望的思考档位(low/medium/high)。
|
||||
// 解析失败或未携带 thinkingConfig 时返回 "high"。
|
||||
func geminiThinkingLevelFromBody(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return "high"
|
||||
}
|
||||
var probe geminiThinkingConfigProbe
|
||||
if err := json.Unmarshal(body, &probe); err != nil || probe.GenerationConfig.ThinkingConfig == nil {
|
||||
return "high"
|
||||
}
|
||||
tc := probe.GenerationConfig.ThinkingConfig
|
||||
switch strings.ToLower(strings.TrimSpace(tc.ThinkingLevel)) {
|
||||
case "low":
|
||||
return "low"
|
||||
case "medium":
|
||||
return "medium"
|
||||
case "high":
|
||||
return "high"
|
||||
}
|
||||
if tc.ThinkingBudget == nil {
|
||||
return "high"
|
||||
}
|
||||
budget, err := tc.ThinkingBudget.Float64()
|
||||
if err != nil {
|
||||
return "high"
|
||||
}
|
||||
switch {
|
||||
case budget < 0:
|
||||
return "high" // -1 = 动态思考
|
||||
case budget <= geminiThinkingBudgetLowMax:
|
||||
return "low" // 含 0(关闭思考):上游没有"无思考"变体,取最浅档,budget 本身仍原样透传
|
||||
case budget <= geminiThinkingBudgetMediumMax:
|
||||
return "medium"
|
||||
default:
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
|
||||
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
|
||||
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
|
||||
func accountRawModelMappingHasKey(account *Account, key string) bool {
|
||||
if account == nil || account.Credentials == nil {
|
||||
return false
|
||||
}
|
||||
raw, _ := account.Credentials["model_mapping"].(map[string]any)
|
||||
_, ok := raw[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
|
||||
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
|
||||
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
|
||||
if account == nil {
|
||||
return "", false
|
||||
}
|
||||
model := strings.TrimSpace(strings.TrimPrefix(requestedModel, "models/"))
|
||||
if !strings.HasPrefix(model, "gemini-") || hasGeminiThinkingVariantSuffix(model) {
|
||||
return "", false
|
||||
}
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
|
||||
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
|
||||
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
|
||||
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
|
||||
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
|
||||
// 正是导致 404 的来源,不能算作用户意图。
|
||||
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
|
||||
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
preferred := geminiThinkingLevelFromBody(body)
|
||||
order := []string{preferred}
|
||||
for _, level := range []string{"high", "medium", "low", "tiered"} {
|
||||
if level != preferred {
|
||||
order = append(order, level)
|
||||
}
|
||||
}
|
||||
for _, level := range order {
|
||||
candidate := model + "-" + level
|
||||
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
|
||||
return mapped, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newAntigravityAccountWithMapping(mapping map[string]string) *Account {
|
||||
mappingAny := make(map[string]any, len(mapping))
|
||||
for k, v := range mapping {
|
||||
mappingAny[k] = v
|
||||
}
|
||||
return &Account{
|
||||
ID: 1,
|
||||
Name: "ag",
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": mappingAny,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiThinkingLevelFromBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"empty body", ``, "high"},
|
||||
{"no thinkingConfig", `{"contents":[]}`, "high"},
|
||||
{"budget -1 dynamic", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, "high"},
|
||||
{"budget 0 off", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, "low"},
|
||||
{"budget 1000 (agy low)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":1000}}}`, "low"},
|
||||
{"budget 4000 (agy medium)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`, "medium"},
|
||||
{"budget 24576", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, "high"},
|
||||
{"thinkingLevel wins over budget", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576,"thinkingLevel":"low"}}}`, "low"},
|
||||
{"thinkingLevel medium", `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"MEDIUM"}}}`, "medium"},
|
||||
{"invalid json", `{"generationConfig":`, "high"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, geminiThinkingLevelFromBody([]byte(tt.body)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGeminiThinkingVariant(t *testing.T) {
|
||||
// 与生产 Antigravity 账号从上游同步来的目录一致:只有带后缀的变体,没有裸名。
|
||||
catalog := map[string]string{
|
||||
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
|
||||
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
||||
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
|
||||
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
|
||||
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
|
||||
"gemini-2.5-flash": "gemini-2.5-flash",
|
||||
}
|
||||
budget := func(v string) []byte {
|
||||
return []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"thinkingBudget":` + v + `}}}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mapping map[string]string
|
||||
model string
|
||||
body []byte
|
||||
want string
|
||||
wantMatch bool
|
||||
}{
|
||||
{"agy high (-1) -> -high", catalog, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
|
||||
{"agy medium (4000) -> -medium", catalog, "gemini-3.8-flash", budget("4000"), "gemini-3.8-flash-medium", true},
|
||||
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
|
||||
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
|
||||
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
|
||||
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
|
||||
{"only -high exists: low request degrades to high", map[string]string{
|
||||
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
|
||||
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
|
||||
{"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true},
|
||||
{"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false},
|
||||
{"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false},
|
||||
{"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false},
|
||||
{"non-gemini model: untouched", catalog, "claude-sonnet-4-6", budget("-1"), "", false},
|
||||
{"explicit bare alias wins over variants", map[string]string{
|
||||
"gemini-3.8-flash": "gemini-3.8-flash-tiered",
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
||||
}, "gemini-3.8-flash", budget("-1"), "", false},
|
||||
{"variant value is itself a mapping target", map[string]string{
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-tiered",
|
||||
}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true},
|
||||
{"user-written bare identity passthrough: respected", map[string]string{
|
||||
"gemini-3.8-flash": "gemini-3.8-flash",
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
||||
}, "gemini-3.8-flash", budget("1000"), "", false},
|
||||
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
|
||||
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
|
||||
// 但 credentials 里没有这条,应当继续推导变体。
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
|
||||
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
|
||||
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := newAntigravityAccountWithMapping(tt.mapping)
|
||||
got, matched := resolveGeminiThinkingVariant(account, tt.model, tt.body)
|
||||
require.Equal(t, tt.wantMatch, matched)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil account", func(t *testing.T) {
|
||||
got, matched := resolveGeminiThinkingVariant(nil, "gemini-3.8-flash", budget("-1"))
|
||||
require.False(t, matched)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
@@ -834,6 +834,16 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
|
||||
require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists())
|
||||
|
||||
mediaBody := []byte(`{"model":"deepseek-v4.1-flash","store":true,"input":[{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}]}`)
|
||||
mediaNormalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, mediaBody)
|
||||
require.False(t, gjson.GetBytes(mediaNormalized, "store").Bool())
|
||||
require.Equal(t, gjson.String, gjson.GetBytes(mediaNormalized, "input.1.output").Type)
|
||||
require.NotContains(t, gjson.GetBytes(mediaNormalized, "input.1.output").String(), "data:image/png")
|
||||
require.Equal(t, "message", gjson.GetBytes(mediaNormalized, "input.2.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(mediaNormalized, "input.2.role").String())
|
||||
require.Equal(t, "input_image", gjson.GetBytes(mediaNormalized, "input.2.content.1.type").String())
|
||||
require.Equal(t, "data:image/png;base64,AQID", gjson.GetBytes(mediaNormalized, "input.2.content.1.image_url").String())
|
||||
|
||||
// 非 responses 协议(deepseek CC 账号)原样返回
|
||||
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
|
||||
|
||||
@@ -74,6 +74,53 @@ func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T
|
||||
require.Equal(t, "message", input[0].(map[string]any)["type"])
|
||||
}
|
||||
|
||||
// Codex 的 codex_app 工具(如 automation_update)把 parameters 根节点声明成对象
|
||||
// 联合;Responses→Anthropic 转换后 input_schema 顶部不能再出现联合关键字。
|
||||
func TestAdaptResponsesClientToolsForAnthropic_FlattensRootUnionSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`{
|
||||
"model":"claude-opus-5",
|
||||
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"create an automation"}]}],
|
||||
"tools":[{"type":"namespace","name":"codex_app","tools":[{
|
||||
"type":"function",
|
||||
"name":"automation_update",
|
||||
"description":"Create, update, view, or delete recurring automations",
|
||||
"parameters":{"oneOf":[
|
||||
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
|
||||
{"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]}
|
||||
]}
|
||||
}]}]
|
||||
}`)
|
||||
|
||||
adapted, _, err := adaptResponsesClientToolsForAnthropic(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var responsesReq apicompat.ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
|
||||
|
||||
claudeReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, claudeReq.Tools, 1)
|
||||
|
||||
tool := claudeReq.Tools[0]
|
||||
require.Equal(t, "codex_app__automation_update", tool.Name)
|
||||
|
||||
var schema map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(tool.InputSchema, &schema))
|
||||
require.JSONEq(t, `"object"`, string(schema["type"]))
|
||||
require.NotContains(t, schema, "oneOf")
|
||||
require.NotContains(t, schema, "anyOf")
|
||||
require.NotContains(t, schema, "allOf")
|
||||
require.JSONEq(t, `["mode","id"]`, string(schema["required"]))
|
||||
|
||||
wire, err := json.Marshal(tool)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(wire), `"input_schema":{"oneOf"`)
|
||||
require.NotContains(t, string(wire), `"input_schema":{"anyOf"`)
|
||||
require.NotContains(t, string(wire), `"input_schema":{"allOf"`)
|
||||
}
|
||||
|
||||
func namespaceToolAnthropicStream() string {
|
||||
return strings.Join([]string{
|
||||
`event: message_start`,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -483,6 +484,32 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// AntigravityGeminiModelIDs returns the effective Gemini mappings of schedulable
|
||||
// Antigravity accounts. Mixed routes must additionally respect the account opt-in.
|
||||
func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Context, groupID *int64, requireMixed bool) ([]string, error) {
|
||||
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, account := range accounts {
|
||||
if account.Platform != PlatformAntigravity || (requireMixed && !account.IsMixedSchedulingEnabled()) {
|
||||
continue
|
||||
}
|
||||
for model := range account.GetModelMapping() {
|
||||
if isAntigravityGeminiModel(model) {
|
||||
seen[model] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
models := make([]string, 0, len(seen))
|
||||
for model := range seen {
|
||||
models = append(models, model)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
|
||||
func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context, groupID *int64) (bool, error) {
|
||||
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// geminiClientRejectsSSEComments 判断下游客户端是否是「无法容忍 SSE 注释行」的 Google GenAI SDK。
|
||||
//
|
||||
// Gemini 原生流里我们用 ":\n\n" 作为空闲心跳(防止代理/Cloudflare Tunnel 断开),但官方 SDK 里
|
||||
// 有两个实现不按 SSE 规范忽略注释行:
|
||||
// - go-genai(Antigravity CLI 在用):api_client.go iterateResponseStream 对任何非 "data:" 前缀的
|
||||
// 事件直接报 "invalid stream chunk: :",整个流中断;
|
||||
// - python-genai:_api_client.py 把非 "data:" 行当作错误 JSON 拼接后 json.loads,同样抛错。
|
||||
//
|
||||
// js-genai 用的是规范的 event-stream 解析器,能正确忽略注释。Google 官方 API 从不下发注释行,
|
||||
// 所以这两个 SDK 从未处理过这种输入。识别依据是 SDK 固定携带的 User-Agent / X-Goog-Api-Client,
|
||||
// 形如 "google-genai-sdk/1.71.0 gl-go/go1.28 ..." 或 "google-genai-sdk/1.x gl-python/3.12"。
|
||||
func geminiClientRejectsSSEComments(clientHint string) bool {
|
||||
hint := strings.ToLower(strings.TrimSpace(clientHint))
|
||||
if !strings.Contains(hint, "google-genai-sdk/") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(hint, "gl-go/") || strings.Contains(hint, "gl-python/")
|
||||
}
|
||||
|
||||
// downstreamRejectsSSEComments 从请求头识别下游客户端(同时看 User-Agent 与 X-Goog-Api-Client)。
|
||||
func downstreamRejectsSSEComments(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
return geminiClientRejectsSSEComments(c.GetHeader("User-Agent")) ||
|
||||
geminiClientRejectsSSEComments(c.GetHeader("X-Goog-Api-Client"))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiClientRejectsSSEComments(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hint string
|
||||
want bool
|
||||
}{
|
||||
{"go-genai (Antigravity CLI)", "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03 cl/951519500 +3ebc191975 X:fieldtrack,boringcrypto", true},
|
||||
{"python-genai", "google-genai-sdk/1.20.0 gl-python/3.12.4", true},
|
||||
{"js-genai tolerates comments", "google-genai-sdk/1.9.0 gl-node/22.3.0", false},
|
||||
{"gemini-cli", "GeminiCLI/0.60.0 (darwin; arm64)", false},
|
||||
{"curl", "curl/8.7.1", false},
|
||||
{"empty", "", false},
|
||||
{"case-insensitive", "Google-GenAI-SDK/1.0.0 GL-Go/go1.27", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, geminiClientRejectsSSEComments(tc.hint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamRejectsSSECommentsReadsBothHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
|
||||
require.False(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
c.Request.Header.Set("X-Goog-Api-Client", "google-genai-sdk/1.71.0 gl-go/go1.28")
|
||||
require.True(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
c.Request.Header.Del("X-Goog-Api-Client")
|
||||
c.Request.Header.Set("User-Agent", "google-genai-sdk/1.71.0 gl-go/go1.28")
|
||||
require.True(t, downstreamRejectsSSEComments(c))
|
||||
|
||||
require.False(t, downstreamRejectsSSEComments(nil))
|
||||
}
|
||||
|
||||
// runAntigravityGeminiStreamWithIdle 起一条上游流:先发一个 data 事件,然后空闲 idle 时长再关闭,
|
||||
// 返回写给下游的全部字节。用来观察空闲期间网关是否发了 ":\n\n" 心跳。
|
||||
func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle time.Duration) string {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newAntigravityCompatService(
|
||||
config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamKeepaliveInterval: 1},
|
||||
nil,
|
||||
)
|
||||
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
|
||||
if userAgent != "" {
|
||||
c.Request.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
reader, writer := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
|
||||
done <- err
|
||||
}()
|
||||
_, err := io.WriteString(
|
||||
writer,
|
||||
`data: {"response":{"responseId":"resp_1","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`+"\n\n",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(idle)
|
||||
require.NoError(t, writer.Close())
|
||||
require.NoError(t, <-done)
|
||||
require.NoError(t, reader.Close())
|
||||
return recorder.Body.String()
|
||||
}
|
||||
|
||||
func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) {
|
||||
out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond)
|
||||
require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive")
|
||||
require.Contains(t, out, `"text":"partial"`)
|
||||
}
|
||||
|
||||
func TestAntigravityGeminiStreamSkipsCommentKeepaliveForGoGenai(t *testing.T) {
|
||||
out := runAntigravityGeminiStreamWithIdle(t, "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03", 1200*time.Millisecond)
|
||||
require.Contains(t, out, `"text":"partial"`)
|
||||
for _, event := range strings.Split(out, "\n\n") {
|
||||
require.False(t, strings.HasPrefix(event, ":"), "go-genai must never receive an SSE comment event, got %q", event)
|
||||
}
|
||||
}
|
||||
@@ -41,10 +41,13 @@ func (e GrokMediaEndpoint) RequiresRequestBody() bool {
|
||||
}
|
||||
|
||||
func (e GrokMediaEndpoint) IsVideoLookupRequest() bool {
|
||||
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent
|
||||
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
|
||||
}
|
||||
|
||||
func (e GrokMediaEndpoint) IsGenerationRequest() bool {
|
||||
if e == SeedanceEndpointCreate {
|
||||
return true
|
||||
}
|
||||
switch e {
|
||||
case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions:
|
||||
return true
|
||||
@@ -337,6 +340,12 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount(
|
||||
// the ownership key; video lookups must neither escape nor refresh that key.
|
||||
func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
|
||||
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel string,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
return s.SelectMediaVideoRequestAccount(ctx, groupID, sessionHash, accountID, requestedModel, PlatformGrok)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) SelectMediaVideoRequestAccount(
|
||||
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel, platform string,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerSessionSticky}
|
||||
if accountID <= 0 || strings.TrimSpace(sessionHash) == "" {
|
||||
@@ -345,7 +354,7 @@ func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
|
||||
ctx = s.withOpenAIGroupPrivacyRequirement(WithOpenAIProfitControlSuppressed(ctx), groupID)
|
||||
scheduler := &defaultOpenAIAccountScheduler{service: s}
|
||||
selection, _, err := scheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{
|
||||
GroupID: groupID, Platform: PlatformGrok, SessionHash: sessionHash,
|
||||
GroupID: groupID, Platform: platform, SessionHash: sessionHash,
|
||||
StickyAccountID: accountID, PreserveStickyBinding: true, DisableStickyEscape: true,
|
||||
RequestedModel: requestedModel, RequiredTransport: OpenAIUpstreamTransportHTTPSSE,
|
||||
RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID),
|
||||
|
||||
@@ -961,15 +961,15 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 因子(use-it-or-lose-it):在拥有「未来会话窗口结束时间」的账号中,
|
||||
// Reset 因子(use-it-or-lose-it):优先读取 Codex 5h 重置时间,回退到会话窗口。
|
||||
// 剩余时间越短 → 因子越接近 1(越早重置越优先用尽)。无活跃窗口的账号因子为 0。
|
||||
// 仅在 weights.Reset > 0 时计算,默认关闭不影响原有行为。
|
||||
minResetRemaining, maxResetRemaining := 0.0, 0.0
|
||||
hasResetSample := false
|
||||
if weights.Reset > 0 {
|
||||
for _, candidate := range candidates {
|
||||
end := candidate.account.SessionWindowEnd
|
||||
if end == nil || !now.Before(*end) {
|
||||
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remaining := end.Sub(now).Seconds()
|
||||
@@ -1002,7 +1002,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
|
||||
}
|
||||
resetFactor := 0.0
|
||||
if weights.Reset > 0 && hasResetSample {
|
||||
if end := item.account.SessionWindowEnd; end != nil && now.Before(*end) {
|
||||
if end, ok := openAISchedulingResetWindowEnd(item.account, now); ok {
|
||||
if maxResetRemaining > minResetRemaining {
|
||||
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
|
||||
} else {
|
||||
@@ -2732,8 +2732,8 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
|
||||
}
|
||||
if weights.Reset > 0 {
|
||||
for _, candidate := range candidates {
|
||||
end := candidate.account.SessionWindowEnd
|
||||
if end == nil || !now.Before(*end) {
|
||||
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remaining := end.Sub(now).Seconds()
|
||||
@@ -2763,7 +2763,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
|
||||
ttftFactor := 0.5
|
||||
resetFactor := 0.0
|
||||
if weights.Reset > 0 && hasResetSample {
|
||||
if end := candidate.account.SessionWindowEnd; end != nil && now.Before(*end) {
|
||||
if end, ok := openAISchedulingResetWindowEnd(candidate.account, now); ok {
|
||||
if maxResetRemaining > minResetRemaining {
|
||||
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
|
||||
} else {
|
||||
@@ -2952,22 +2952,89 @@ func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 {
|
||||
if account == nil || len(account.Extra) == 0 || openAIQuotaHeadroomSnapshotStale(account.Extra, now) {
|
||||
return openAIQuotaHeadroomNeutralFactor
|
||||
}
|
||||
primaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_primary_used_percent", "codex_7d_used_percent")
|
||||
if !ok || openAIQuotaWindowResetAny(account.Extra, now, "primary", "7d") {
|
||||
window5h, window7d := openAICanonicalQuotaWindows(account.Extra, now)
|
||||
if !window7d.hasUsed || window7d.reset {
|
||||
return openAIQuotaHeadroomNeutralFactor
|
||||
}
|
||||
|
||||
factor := 1 - clamp01(primaryUsedPercent/100)
|
||||
if secondaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_secondary_used_percent", "codex_5h_used_percent"); ok &&
|
||||
!openAIQuotaWindowResetAny(account.Extra, now, "secondary", "5h") {
|
||||
secondaryRemaining := 1 - clamp01(secondaryUsedPercent/100)
|
||||
if secondaryRemaining < openAIQuotaHeadroomSecondaryLowRemain {
|
||||
factor := 1 - clamp01(window7d.usedPercent/100)
|
||||
if window5h.hasUsed && !window5h.reset {
|
||||
remaining := 1 - clamp01(window5h.usedPercent/100)
|
||||
if remaining < openAIQuotaHeadroomSecondaryLowRemain {
|
||||
factor *= openAIQuotaHeadroomNeutralFactor
|
||||
}
|
||||
}
|
||||
return factor
|
||||
}
|
||||
|
||||
type openAICanonicalQuotaWindow struct {
|
||||
usedPercent float64
|
||||
hasUsed bool
|
||||
reset bool
|
||||
}
|
||||
|
||||
// 规范字段优先;历史原始字段复用写入端 Normalize 的窗口分类,不能固定把 primary 当作 7d。
|
||||
func openAICanonicalQuotaWindows(extra map[string]any, now time.Time) (window5h, window7d openAICanonicalQuotaWindow) {
|
||||
if used, ok := resolveAccountExtraNumber(extra, "codex_5h_used_percent"); ok {
|
||||
window5h = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "5h", now)}
|
||||
}
|
||||
if used, ok := resolveAccountExtraNumber(extra, "codex_7d_used_percent"); ok {
|
||||
window7d = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "7d", now)}
|
||||
}
|
||||
if window5h.hasUsed && window7d.hasUsed {
|
||||
return window5h, window7d
|
||||
}
|
||||
|
||||
snapshot := &OpenAICodexUsageSnapshot{}
|
||||
if used, ok := resolveAccountExtraNumber(extra, "codex_primary_used_percent"); ok {
|
||||
snapshot.PrimaryUsedPercent = &used
|
||||
}
|
||||
if used, ok := resolveAccountExtraNumber(extra, "codex_secondary_used_percent"); ok {
|
||||
snapshot.SecondaryUsedPercent = &used
|
||||
}
|
||||
if minutes := parseExtraInt(extra["codex_primary_window_minutes"]); minutes > 0 {
|
||||
snapshot.PrimaryWindowMinutes = &minutes
|
||||
}
|
||||
if minutes := parseExtraInt(extra["codex_secondary_window_minutes"]); minutes > 0 {
|
||||
snapshot.SecondaryWindowMinutes = &minutes
|
||||
}
|
||||
normalized := snapshot.Normalize()
|
||||
if normalized == nil {
|
||||
return window5h, window7d
|
||||
}
|
||||
fromRaw := func(used *float64) openAICanonicalQuotaWindow {
|
||||
if used == nil {
|
||||
return openAICanonicalQuotaWindow{}
|
||||
}
|
||||
// Normalize 保留原始字段指针,据此配对同一个窗口的用量和重置时间。
|
||||
window := "secondary"
|
||||
if used == snapshot.PrimaryUsedPercent {
|
||||
window = "primary"
|
||||
}
|
||||
return openAICanonicalQuotaWindow{usedPercent: *used, hasUsed: true, reset: openAIQuotaWindowReset(extra, window, now)}
|
||||
}
|
||||
if !window5h.hasUsed {
|
||||
window5h = fromRaw(normalized.Used5hPercent)
|
||||
}
|
||||
if !window7d.hasUsed {
|
||||
window7d = fromRaw(normalized.Used7dPercent)
|
||||
}
|
||||
return window5h, window7d
|
||||
}
|
||||
|
||||
func openAISchedulingResetWindowEnd(account *Account, now time.Time) (time.Time, bool) {
|
||||
if account == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if end, ok := openAICodexWindowResetAt(account.Extra, "5h"); ok && now.Before(end) {
|
||||
return end, true
|
||||
}
|
||||
if end := account.SessionWindowEnd; end != nil && now.Before(*end) {
|
||||
return *end, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool {
|
||||
updatedRaw, ok := extra["codex_usage_updated_at"]
|
||||
if !ok {
|
||||
@@ -2980,15 +3047,6 @@ func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool
|
||||
return now.Sub(updatedAt) >= openAIQuotaHeadroomSnapshotStaleAfter
|
||||
}
|
||||
|
||||
func openAIQuotaWindowResetAny(extra map[string]any, now time.Time, windows ...string) bool {
|
||||
for _, window := range windows {
|
||||
if openAIQuotaWindowReset(extra, window, now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
switch {
|
||||
case value < 0:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIQuotaHeadroomFactorUsesCanonicalWindows(t *testing.T) {
|
||||
now := time.Now()
|
||||
shortMins, longMins := 300, 10080
|
||||
shortReset, longReset := 3600, 86400
|
||||
shortUsed, longUsed := 96.0, 25.0
|
||||
for _, primaryIs5h := range []bool{true, false} {
|
||||
name := "primary_is_7d"
|
||||
snapshot := &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &longUsed, PrimaryWindowMinutes: &longMins, PrimaryResetAfterSeconds: &longReset,
|
||||
SecondaryUsedPercent: &shortUsed, SecondaryWindowMinutes: &shortMins, SecondaryResetAfterSeconds: &shortReset,
|
||||
}
|
||||
if primaryIs5h {
|
||||
name = "primary_is_5h"
|
||||
snapshot = &OpenAICodexUsageSnapshot{
|
||||
PrimaryUsedPercent: &shortUsed, PrimaryWindowMinutes: &shortMins, PrimaryResetAfterSeconds: &shortReset,
|
||||
SecondaryUsedPercent: &longUsed, SecondaryWindowMinutes: &longMins, SecondaryResetAfterSeconds: &longReset,
|
||||
}
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, rawOnly := range []bool{false, true} {
|
||||
fields := "canonical_and_raw"
|
||||
if rawOnly {
|
||||
fields = "raw_only"
|
||||
}
|
||||
t.Run(fields, func(t *testing.T) {
|
||||
extra := buildCodexUsageExtraUpdates(snapshot, now)
|
||||
if rawOnly {
|
||||
for key := range extra {
|
||||
if strings.HasPrefix(key, "codex_5h_") || strings.HasPrefix(key, "codex_7d_") {
|
||||
delete(extra, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 周余量 75%,5h 余量低于 10% 时沿用现有的减半规则。
|
||||
require.InDelta(t, 0.375, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIQuotaHeadroomFactorCanonicalPrecedence(t *testing.T) {
|
||||
now := time.Now()
|
||||
extra := map[string]any{
|
||||
"codex_primary_used_percent": 40.0,
|
||||
"codex_secondary_used_percent": 20.0,
|
||||
"codex_usage_updated_at": now.Format(time.RFC3339),
|
||||
}
|
||||
// 没有窗口长度的历史数据仍沿用 primary=7d、secondary=5h。
|
||||
require.InDelta(t, 0.6, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
|
||||
extra["codex_7d_used_percent"] = 80.0
|
||||
extra["codex_5h_used_percent"] = 10.0
|
||||
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
|
||||
|
||||
// 规范窗口有效时,不应被另一个原始窗口的过期时间误判为已重置。
|
||||
extra["codex_7d_reset_at"] = now.Add(time.Hour).Format(time.RFC3339)
|
||||
extra["codex_primary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
|
||||
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
|
||||
extra["codex_7d_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
|
||||
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
|
||||
}
|
||||
|
||||
func TestOpenAIQuotaHeadroomFactorRawWindowReset(t *testing.T) {
|
||||
now := time.Now()
|
||||
extra := map[string]any{
|
||||
"codex_primary_used_percent": 96.0,
|
||||
"codex_primary_window_minutes": 300,
|
||||
"codex_primary_reset_at": now.Add(-time.Minute).Format(time.RFC3339),
|
||||
"codex_secondary_used_percent": 25.0,
|
||||
"codex_secondary_reset_at": now.Add(time.Hour).Format(time.RFC3339),
|
||||
"codex_usage_updated_at": now.Add(-time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
require.InDelta(t, 0.75, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
|
||||
extra["codex_secondary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
|
||||
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
|
||||
}
|
||||
|
||||
func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) {
|
||||
now := time.Now()
|
||||
scheduler := openAIResetTestScheduler(1)
|
||||
accounts := []*Account{
|
||||
{ID: 1, Extra: map[string]any{"codex_5h_reset_at": now.Add(10 * time.Minute).Format(time.RFC3339)}},
|
||||
{ID: 2, Extra: map[string]any{"codex_5h_reset_at": now.Add(4 * time.Hour).Format(time.RFC3339)}},
|
||||
}
|
||||
plan := scheduler.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, accounts, nil)
|
||||
scores := openAIPlanScores(plan)
|
||||
require.InDelta(t, 1, scores[1]-scores[2], 1e-9)
|
||||
|
||||
snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier)
|
||||
for _, account := range accounts {
|
||||
require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAISchedulingResetWindowEnd(t *testing.T) {
|
||||
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||
updated := now.Add(-30 * time.Minute)
|
||||
account := &Account{Extra: map[string]any{
|
||||
"codex_usage_updated_at": updated.Format(time.RFC3339),
|
||||
"codex_5h_reset_after_seconds": 3600,
|
||||
}}
|
||||
end, ok := openAISchedulingResetWindowEnd(account, now)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, updated.Add(time.Hour), end)
|
||||
|
||||
account.Extra["codex_5h_reset_at"] = now.Add(10 * time.Minute).Format(time.RFC3339)
|
||||
end, ok = openAISchedulingResetWindowEnd(account, now)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, now.Add(10*time.Minute), end)
|
||||
|
||||
delete(account.Extra, "codex_5h_reset_at")
|
||||
account.Extra["codex_5h_reset_after_seconds"] = 60
|
||||
_, ok = openAISchedulingResetWindowEnd(account, now)
|
||||
require.False(t, ok)
|
||||
account.Extra["codex_5h_reset_after_seconds"] = 3600
|
||||
delete(account.Extra, "codex_usage_updated_at")
|
||||
_, ok = openAISchedulingResetWindowEnd(account, now)
|
||||
require.False(t, ok, "缺少采样时间时不能把相对倒计时锚定到当前时间")
|
||||
account.Extra["codex_usage_updated_at"] = "invalid"
|
||||
_, ok = openAISchedulingResetWindowEnd(account, now)
|
||||
require.False(t, ok)
|
||||
|
||||
sessionEnd := now.Add(2 * time.Hour)
|
||||
account.SessionWindowEnd = &sessionEnd
|
||||
end, ok = openAISchedulingResetWindowEnd(account, now)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, sessionEnd, end)
|
||||
_, ok = openAISchedulingResetWindowEnd(nil, now)
|
||||
require.False(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func requiresSystemChatRole(account *Account, targetURL string) bool {
|
||||
if account == nil || account.Type != AccountTypeAPIKey {
|
||||
return false
|
||||
}
|
||||
switch account.Platform {
|
||||
case PlatformDeepseek, PlatformKimi, PlatformZhipu:
|
||||
return true
|
||||
}
|
||||
// OpenAI-compatible accounts can point at the same strict providers. Match
|
||||
// the selected destination's exact hostname, never the requested model ID.
|
||||
u, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(u.Hostname()) {
|
||||
case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an
|
||||
// account and destination have been selected. Unlike the Responses bridge, it
|
||||
// does not reorder/merge messages or downgrade instructions to user messages.
|
||||
// RawMessage preserves unknown fields and numeric precision. The input remains
|
||||
// untouched so a retry on another account sees the original developer roles.
|
||||
func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) {
|
||||
if !requiresSystemChatRole(account, targetURL) {
|
||||
return body, nil
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &root) != nil || root == nil {
|
||||
return nil, errors.New("chat role normalization: invalid request object")
|
||||
}
|
||||
raw, exists := root["messages"]
|
||||
if !exists {
|
||||
return body, nil
|
||||
}
|
||||
var messages []json.RawMessage
|
||||
if json.Unmarshal(raw, &messages) != nil {
|
||||
return nil, errors.New("chat role normalization: invalid messages array")
|
||||
}
|
||||
changed := false
|
||||
for i, raw := range messages {
|
||||
var message map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &message) != nil || message == nil {
|
||||
return nil, errors.New("chat role normalization: invalid message object")
|
||||
}
|
||||
var role string
|
||||
if json.Unmarshal(message["role"], &role) != nil {
|
||||
return nil, errors.New("chat role normalization: invalid message role")
|
||||
}
|
||||
if role != "developer" {
|
||||
continue
|
||||
}
|
||||
message["role"] = json.RawMessage(`"system"`)
|
||||
updated, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, errors.New("chat role normalization: cannot encode message")
|
||||
}
|
||||
messages[i] = updated
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, nil
|
||||
}
|
||||
updated, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
return nil, errors.New("chat role normalization: cannot encode messages")
|
||||
}
|
||||
root["messages"] = updated
|
||||
return json.Marshal(root)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Only the upstream transport is stubbed. Requests go through the actual Chat
|
||||
// dispatcher, model mapping, request construction and response/usage handling.
|
||||
type chatRoleContractUpstream struct {
|
||||
httpUpstreamRecorder
|
||||
strict bool
|
||||
}
|
||||
|
||||
func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
status, contentType := http.StatusOK, "application/json"
|
||||
result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}`
|
||||
if u.strict {
|
||||
for _, message := range gjson.GetBytes(body, "messages").Array() {
|
||||
if message.Get("role").String() == "developer" {
|
||||
status = http.StatusBadRequest
|
||||
result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() {
|
||||
contentType = "text/event-stream"
|
||||
result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))}
|
||||
return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, stream := range []bool{false, true} {
|
||||
for _, target := range []struct{ platform, model, base string }{
|
||||
{PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"},
|
||||
{PlatformKimi, "k3", "https://api.kimi.com/coding"},
|
||||
{PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
|
||||
{PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"},
|
||||
{PlatformOpenAI, "k3", "https://api.kimi.com/coding"},
|
||||
{PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"},
|
||||
{PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) {
|
||||
body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream))
|
||||
original := bytes.Clone(body)
|
||||
upstream := &chatRoleContractUpstream{strict: true}
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
account := rawChatCompletionsTestAccount()
|
||||
account.Platform = target.platform
|
||||
account.Credentials["base_url"] = target.base
|
||||
account.Credentials["api_protocol"] = APIProtocolChatCompletions
|
||||
account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model}
|
||||
account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, 9, result.Usage.InputTokens)
|
||||
require.Equal(t, 1, result.Usage.OutputTokens)
|
||||
require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
|
||||
require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
|
||||
require.Equal(t, original, body, "per-account adaptation must not mutate the retry body")
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "pong")
|
||||
if stream {
|
||||
require.Contains(t, recorder.Body.String(), "data: [DONE]")
|
||||
}
|
||||
|
||||
// A subsequent attempt with the same body on a compatible account
|
||||
// must still see developer, not the previous account's system role.
|
||||
upstream.strict = false
|
||||
account.Platform = PlatformOpenAI
|
||||
account.Credentials["base_url"] = "https://compatible.example.test/v1"
|
||||
recorder = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
_, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) {
|
||||
body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`)
|
||||
original := bytes.Clone(body)
|
||||
want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`)
|
||||
for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} {
|
||||
t.Run(platform, func(t *testing.T) {
|
||||
account := &Account{Platform: platform, Type: AccountTypeAPIKey}
|
||||
got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, want, string(got))
|
||||
require.Contains(t, string(got), "9007199254740993")
|
||||
require.Equal(t, original, body)
|
||||
again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, got, again)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) {
|
||||
body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`)
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) {
|
||||
// Neither model names nor host-like strings in the path/userinfo may enable
|
||||
// normalization on an otherwise compatible provider.
|
||||
body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }")
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
for _, target := range []string{
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
"https://relay.example.test/v1/chat/completions",
|
||||
"https://api.deepseek.com.example.test/v1/chat/completions",
|
||||
"https://example.test/api.kimi.com/chat/completions",
|
||||
"https://api.deepseek.com@example.test/v1/chat/completions",
|
||||
"://invalid-url",
|
||||
} {
|
||||
got, err := normalizeStrictChatDeveloperRoles(account, target, body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} {
|
||||
got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) {
|
||||
account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
for _, body := range []string{
|
||||
"{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }",
|
||||
`{"messages":[]}`,
|
||||
`{"input":[{"role":"developer","content":"Responses is not Chat"}]}`,
|
||||
} {
|
||||
got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) {
|
||||
account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} {
|
||||
_, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
}
|
||||
@@ -2473,13 +2473,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error {
|
||||
return errors.New("missing top-level models array")
|
||||
}
|
||||
models = bytes.TrimSpace(models)
|
||||
var entries []json.RawMessage
|
||||
if len(models) == 0 || models[0] != '[' {
|
||||
return errors.New("top-level models field is not an array")
|
||||
}
|
||||
if err := json.Unmarshal(models, &entries); err != nil {
|
||||
return fmt.Errorf("decode top-level models array: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2562,6 +2562,11 @@ func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) {
|
||||
require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`)))
|
||||
require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`)))
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
|
||||
@@ -1228,7 +1228,15 @@ func normalizeOpenAIModelForUpstream(account *Account, model string) string {
|
||||
if account == nil || account.UsesOpenAICodexProtocol() {
|
||||
return normalizeCodexModel(model)
|
||||
}
|
||||
return strings.TrimSpace(model)
|
||||
model = strings.TrimSpace(model)
|
||||
if account.Platform == PlatformDeepseek {
|
||||
// DeepSeek 走 OpenAI 兼容入口,不经 Anthropic 入站的 [1m] 后缀归一
|
||||
// (parseGatewayRequestCurrentBody 仅处理 PlatformAnthropic 协议)。
|
||||
// 官方 Claude Code 接入要求 ANTHROPIC_MODEL=deepseek-flash[1m] 写法,
|
||||
// 出站前剥离泄漏的客户端上下文后缀,转发规范名给上游。
|
||||
return normalizeClaudeCodeLongContextModel(model)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func SupportsVerbosity(model string) bool {
|
||||
|
||||
@@ -114,7 +114,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
|
||||
}
|
||||
MarkOpsStreamError(c, errType, message, statusCode)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"type": "response.failed",
|
||||
"type": "response.failed",
|
||||
"sequence_number": 0,
|
||||
"response": map[string]any{
|
||||
"id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
"object": "response",
|
||||
@@ -143,7 +144,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
|
||||
// 携带完整 response 对象。Codex 的 SSE 解析只从 output_item.done 收集 item,
|
||||
// 并要求 response.completed 的 response.id 必填、usage(若存在)必须携带
|
||||
// input_tokens/output_tokens/total_tokens 整数字段,否则整条 completed 事件
|
||||
// 解析失败,故此处做兜底修补。
|
||||
// 解析失败,故此处做兜底修补。每帧还要带单调的 sequence_number:grok-build
|
||||
// 把它当必填,缺了整轮反序列化失败。
|
||||
func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
|
||||
if len(finalResponse) == 0 || !gjson.ValidBytes(finalResponse) {
|
||||
return nil, false
|
||||
@@ -176,12 +178,19 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
outputIndex := 0
|
||||
appendEvent := func(eventType string, data []byte) {
|
||||
sequenceNumber := 0
|
||||
appendEvent := func(eventType string, data []byte) bool {
|
||||
numbered, err := sjson.SetBytes(data, "sequence_number", sequenceNumber)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sequenceNumber++
|
||||
_, _ = buf.WriteString("event: ")
|
||||
_, _ = buf.WriteString(eventType)
|
||||
_, _ = buf.WriteString("\ndata: ")
|
||||
_, _ = buf.Write(data)
|
||||
_, _ = buf.Write(numbered)
|
||||
_, _ = buf.WriteString("\n\n")
|
||||
return true
|
||||
}
|
||||
for _, item := range gjson.GetBytes(response, "output").Array() {
|
||||
if !item.IsObject() {
|
||||
@@ -195,7 +204,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
appendEvent("response.output_item.done", event)
|
||||
if !appendEvent("response.output_item.done", event) {
|
||||
return nil, false
|
||||
}
|
||||
outputIndex++
|
||||
}
|
||||
|
||||
@@ -203,7 +214,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
appendEvent("response.completed", completed)
|
||||
if !appendEvent("response.completed", completed) {
|
||||
return nil, false
|
||||
}
|
||||
return buf.Bytes(), true
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
|
||||
require.Equal(t, "response.output_item.done", events[0][0])
|
||||
first := events[0][1]
|
||||
require.Equal(t, "response.output_item.done", gjson.Get(first, "type").String())
|
||||
require.True(t, gjson.Get(first, "sequence_number").Exists(), "compact 合成帧必须带 sequence_number")
|
||||
require.Equal(t, int64(0), gjson.Get(first, "sequence_number").Int())
|
||||
require.Equal(t, int64(0), gjson.Get(first, "output_index").Int())
|
||||
require.Equal(t, "compaction", gjson.Get(first, "item.type").String())
|
||||
require.Equal(t, "cmp_1", gjson.Get(first, "item.id").String())
|
||||
@@ -81,12 +83,14 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
|
||||
require.True(t, gjson.Get(first, "item.opaque.kept").Bool(), "item 原始字段必须逐字节保留")
|
||||
|
||||
require.Equal(t, "response.output_item.done", events[1][0])
|
||||
require.Equal(t, int64(1), gjson.Get(events[1][1], "sequence_number").Int())
|
||||
require.Equal(t, int64(1), gjson.Get(events[1][1], "output_index").Int())
|
||||
require.Equal(t, "message", gjson.Get(events[1][1], "item.type").String())
|
||||
|
||||
require.Equal(t, "response.completed", events[2][0])
|
||||
completed := events[2][1]
|
||||
require.Equal(t, "response.completed", gjson.Get(completed, "type").String())
|
||||
require.Equal(t, int64(2), gjson.Get(completed, "sequence_number").Int())
|
||||
require.Equal(t, "resp_compact_1", gjson.Get(completed, "response.id").String())
|
||||
require.Equal(t, int64(13), gjson.Get(completed, "response.usage.total_tokens").Int())
|
||||
require.Len(t, gjson.Get(completed, "response.output").Array(), 2)
|
||||
|
||||
@@ -31,8 +31,9 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
|
||||
require.True(t, found, "SSE 帧必须带 data 行: %q", body)
|
||||
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Response struct {
|
||||
Type string `json:"type"`
|
||||
SequenceNumber *int `json:"sequence_number"`
|
||||
Response struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
@@ -42,6 +43,8 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(payload)), &event))
|
||||
|
||||
require.Equal(t, "response.failed", event.Type)
|
||||
require.NotNil(t, event.SequenceNumber, "response.failed 必须带 sequence_number,否则 grok-build 读不出这帧")
|
||||
require.GreaterOrEqual(t, *event.SequenceNumber, 0)
|
||||
require.Equal(t, "response", event.Response.Object)
|
||||
require.Equal(t, "failed", event.Response.Status)
|
||||
require.Greater(t, event.Response.CreatedAt, int64(0),
|
||||
|
||||
@@ -182,6 +182,11 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
userAgent string,
|
||||
grokCacheIdentity string,
|
||||
) (*http.Response, error) {
|
||||
// DeepSeek thinking mode 要求历史 assistant 回传 reasoning_content。
|
||||
// Responses→CC 回退在加密-only / 缺 reasoning item 且缓存未命中时会漏掉该
|
||||
// 字段,上游 400 "The `reasoning_content` in the thinking mode must be
|
||||
// passed back to the API"。在共用出站点补空格占位,真实明文不覆盖。
|
||||
body = ensureDeepSeekChatReasoningPlaceholders(account, body)
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||
releaseUpstreamCtx()
|
||||
|
||||
@@ -178,6 +178,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody)
|
||||
if err != nil {
|
||||
writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
|
||||
customUA := account.GetOpenAIUserAgent()
|
||||
if customUA == "" && account.IsGrokOAuth() {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func openaiPlatformDeepSeekAccount() *Account {
|
||||
return &Account{
|
||||
ID: 18,
|
||||
Name: "openai-deepseek-apikey",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
|
||||
},
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": DefaultDeepseekBaseURL,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func deepSeekChatFallbackTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
URLAllowlist: config.URLAllowlistConfig{
|
||||
Enabled: false,
|
||||
AllowInsecureHTTP: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) {
|
||||
deepSeekAccount := openaiPlatformDeepSeekAccount()
|
||||
nativeDeepSeek := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
otherOpenAI := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"},
|
||||
}
|
||||
|
||||
missing := []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
|
||||
withPlain := []byte(`{"model":"deepseek-chat","messages":[{"role":"assistant","reasoning_content":"real thinking","content":"ok"}]}`)
|
||||
|
||||
t.Run("openai_platform_deepseek_fills_empty_assistant", func(t *testing.T) {
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, missing)
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
|
||||
require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists())
|
||||
require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists())
|
||||
})
|
||||
|
||||
t.Run("native_deepseek_platform_fills_empty_assistant", func(t *testing.T) {
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(nativeDeepSeek, missing)
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
|
||||
})
|
||||
|
||||
t.Run("does_not_overwrite_plaintext", func(t *testing.T) {
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, withPlain)
|
||||
require.Equal(t, "real thinking", gjson.GetBytes(got, "messages.0.reasoning_content").String())
|
||||
require.Equal(t, string(withPlain), string(got), "已有明文时必须原样返回")
|
||||
})
|
||||
|
||||
t.Run("non_deepseek_upstream_unchanged", func(t *testing.T) {
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(otherOpenAI, missing)
|
||||
require.Equal(t, string(missing), string(got))
|
||||
require.False(t, gjson.GetBytes(got, "messages.1.reasoning_content").Exists())
|
||||
})
|
||||
|
||||
t.Run("nil_account_unchanged", func(t *testing.T) {
|
||||
got := ensureDeepSeekChatReasoningPlaceholders(nil, missing)
|
||||
require.Equal(t, string(missing), string(got))
|
||||
})
|
||||
}
|
||||
|
||||
type reasoningHitCache struct {
|
||||
stubGatewayCache
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (c *reasoningHitCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
|
||||
if v, ok := c.values[itemID]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", ErrReasoningContentNotFound
|
||||
}
|
||||
|
||||
func deepSeekChatHistoryWithEncryptedReasoning() []byte {
|
||||
return []byte(`{
|
||||
"model":"gpt-5.6-sol",
|
||||
"stream":false,
|
||||
"input":[
|
||||
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
|
||||
{"type":"function_call","call_id":"call_1","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"ok"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
|
||||
]
|
||||
}`)
|
||||
}
|
||||
|
||||
func newDeepSeekChatFallbackContext(t *testing.T, body []byte) *gin.Context {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return c
|
||||
}
|
||||
|
||||
func newOKChatCompletionsUpstream(requestID, body string) *httpUpstreamRecorder {
|
||||
return &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{requestID}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}}
|
||||
}
|
||||
|
||||
const deepSeekChatFallbackOKBody = `{"id":"chatcmpl_ph","object":"chat.completion","model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
|
||||
|
||||
func TestForwardResponses_DeepSeekChatFallbackInjectsReasoningPlaceholderOnCacheMiss(t *testing.T) {
|
||||
body := deepSeekChatHistoryWithEncryptedReasoning()
|
||||
c := newDeepSeekChatFallbackContext(t, body)
|
||||
upstream := newOKChatCompletionsUpstream("rid_ds_rc_placeholder", deepSeekChatFallbackOKBody)
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: deepSeekChatFallbackTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Contains(t, upstream.lastReq.URL.String(), "/chat/completions")
|
||||
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
|
||||
require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
|
||||
}
|
||||
|
||||
func TestForwardResponses_DeepSeekChatFallbackKeepsCachedReasoningContent(t *testing.T) {
|
||||
body := deepSeekChatHistoryWithEncryptedReasoning()
|
||||
c := newDeepSeekChatFallbackContext(t, body)
|
||||
upstream := newOKChatCompletionsUpstream("rid_ds_rc_cached", deepSeekChatFallbackOKBody)
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: deepSeekChatFallbackTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
cache: &reasoningHitCache{values: map[string]string{"item_enc1": "cached thinking"}},
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
|
||||
}
|
||||
|
||||
func TestForwardResponses_NonDeepSeekChatFallbackDoesNotInjectReasoningPlaceholder(t *testing.T) {
|
||||
body := deepSeekChatHistoryWithEncryptedReasoning()
|
||||
c := newDeepSeekChatFallbackContext(t, body)
|
||||
upstream := newOKChatCompletionsUpstream("rid_other_rc", deepSeekChatFallbackOKBody)
|
||||
account := &Account{
|
||||
ID: 99,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
|
||||
},
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "http://upstream.example",
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: deepSeekChatFallbackTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").Exists())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -141,7 +142,25 @@ func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte
|
||||
if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
|
||||
normalized = stripped
|
||||
}
|
||||
return normalized
|
||||
|
||||
var requestBody map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil {
|
||||
return normalized
|
||||
}
|
||||
input, exists := requestBody["input"]
|
||||
if !exists {
|
||||
return normalized
|
||||
}
|
||||
liftedInput, changed := apicompat.LiftResponsesToolOutputMedia(input)
|
||||
if !changed {
|
||||
return normalized
|
||||
}
|
||||
requestBody["input"] = liftedInput
|
||||
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
|
||||
if err != nil {
|
||||
return normalized
|
||||
}
|
||||
return rebuilt
|
||||
}
|
||||
|
||||
func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool {
|
||||
|
||||
@@ -1464,12 +1464,23 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
// The client may close the stream immediately after receiving its terminal
|
||||
// event, canceling the request context before these durable affinity writes
|
||||
// run. Preserve request values, but give all Redis writes one bounded budget
|
||||
// independent of the downstream connection lifecycle.
|
||||
bindBaseCtx := context.Background()
|
||||
if ctx != nil {
|
||||
bindBaseCtx = context.WithoutCancel(ctx)
|
||||
}
|
||||
bindCtx, cancel := context.WithTimeout(bindBaseCtx, openAIWSStateStoreRedisTimeout)
|
||||
defer cancel()
|
||||
|
||||
groupID := getOpenAIGroupIDFromContext(c)
|
||||
ttl := s.openAIWSResponseStickyTTL()
|
||||
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
|
||||
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(bindCtx, groupID, responseID, account.ID, ttl))
|
||||
if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok {
|
||||
if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 {
|
||||
if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
|
||||
if err := s.BindOpenAIHTTPResponseOwner(bindCtx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
|
||||
logger.L().Warn(
|
||||
"openai.http_bind_response_owner_failed",
|
||||
zap.Int64("group_id", groupID),
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,8 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -399,3 +403,67 @@ func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// deepSeekChatReasoningPlaceholderText 是 Chat Completions 侧 thinking-mode
|
||||
// 占位明文。必须是单个空格:DeepSeek 拒绝空串,非空即可通过;LiteLLM 同样注入
|
||||
// 单个空格。
|
||||
const deepSeekChatReasoningPlaceholderText = " "
|
||||
|
||||
// targetsDeepSeekAPIHost 报告该账号实际上游是否是 DeepSeek 官方 API。
|
||||
// platform=deepseek 直接命中;platform=openai 但 base_url 指向
|
||||
// api.deepseek.com 的映射账号同样命中(Codex 把 GPT 模型名映射到 DeepSeek
|
||||
// 时的典型接入方式)。
|
||||
func targetsDeepSeekAPIHost(account *Account) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if account.Platform == PlatformDeepseek {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(account.GetOpenAIBaseURL()))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ds, err := url.Parse(DefaultDeepseekBaseURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), ds.Hostname())
|
||||
}
|
||||
|
||||
// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant
|
||||
// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的
|
||||
// assistant 消息都回传该字段,否则 400
|
||||
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
|
||||
//
|
||||
// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。
|
||||
// 非 DeepSeek 上游原样返回(字节不变)。
|
||||
func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte {
|
||||
if !targetsDeepSeekAPIHost(account) {
|
||||
return body
|
||||
}
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.IsArray() {
|
||||
return body
|
||||
}
|
||||
updated := body
|
||||
changed := false
|
||||
for i, msg := range messages.Array() {
|
||||
if strings.TrimSpace(msg.Get("role").String()) != "assistant" {
|
||||
continue
|
||||
}
|
||||
if msg.Get("reasoning_content").String() != "" {
|
||||
continue
|
||||
}
|
||||
next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning_content", deepSeekChatReasoningPlaceholderText)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
updated = next
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -710,26 +710,29 @@ func openAICodexSnapshotStaleForPause(extra map[string]any, now time.Time) bool
|
||||
// timestamp and falls back to codex_<window>_reset_after_seconds anchored at
|
||||
// codex_usage_updated_at, mirroring AccountUsageService's window-progress logic.
|
||||
func openAIQuotaWindowReset(extra map[string]any, window string, now time.Time) bool {
|
||||
resetAt, ok := openAICodexWindowResetAt(extra, window)
|
||||
return ok && !now.Before(resetAt)
|
||||
}
|
||||
|
||||
// 绝对时间优先;相对倒计时必须锚定快照采样时间,不能随每次评分向后滑动。
|
||||
func openAICodexWindowResetAt(extra map[string]any, window string) (time.Time, bool) {
|
||||
if len(extra) == 0 {
|
||||
return false
|
||||
return time.Time{}, false
|
||||
}
|
||||
if resetAtRaw, ok := extra["codex_"+window+"_reset_at"]; ok {
|
||||
if resetAt, err := parseTime(fmt.Sprint(resetAtRaw)); err == nil {
|
||||
return !now.Before(resetAt)
|
||||
return resetAt, true
|
||||
}
|
||||
}
|
||||
resetAfter := parseExtraInt(extra["codex_"+window+"_reset_after_seconds"])
|
||||
if resetAfter <= 0 {
|
||||
return false
|
||||
return time.Time{}, false
|
||||
}
|
||||
base := now
|
||||
if updatedRaw, ok := extra["codex_usage_updated_at"]; ok {
|
||||
if updatedAt, err := parseTime(fmt.Sprint(updatedRaw)); err == nil {
|
||||
base = updatedAt
|
||||
}
|
||||
updatedAt, err := parseTime(fmt.Sprint(extra["codex_usage_updated_at"]))
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
resetAt := base.Add(time.Duration(resetAfter) * time.Second)
|
||||
return !now.Before(resetAt)
|
||||
return updatedAt.Add(time.Duration(resetAfter) * time.Second), true
|
||||
}
|
||||
|
||||
func readOpenAIQuotaUsedPercent(extra map[string]any, window string) float64 {
|
||||
|
||||
@@ -589,6 +589,37 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) {
|
||||
require.False(t, owned)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_BindHTTPResponseAccount_DetachesCanceledRequestContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
groupID := int64(4201)
|
||||
c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID})
|
||||
SetOpenAIHTTPResponseOwner(c, 601, 501)
|
||||
|
||||
cache := &responseBindContextProbeCache{}
|
||||
svc := &OpenAIGatewayService{cache: cache}
|
||||
account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
requestCtx, cancelRequest := context.WithCancel(context.Background())
|
||||
cancelRequest()
|
||||
startedAt := time.Now()
|
||||
|
||||
svc.bindHTTPResponseAccount(requestCtx, c, account, "resp_http_canceled_001")
|
||||
|
||||
require.Len(t, cache.setContextErrors, 3)
|
||||
for _, contextErr := range cache.setContextErrors {
|
||||
require.NoError(t, contextErr, "response affinity writes must survive downstream cancellation")
|
||||
}
|
||||
require.Len(t, cache.setDeadlines, 3)
|
||||
for _, deadline := range cache.setDeadlines {
|
||||
require.True(t, deadline.After(startedAt))
|
||||
require.LessOrEqual(t, deadline.Sub(startedAt), openAIWSStateStoreRedisTimeout+100*time.Millisecond)
|
||||
}
|
||||
require.Len(t, cache.sessionBindings, 3)
|
||||
require.Contains(t, cache.sessionBindings, openAIWSResponseAccountCacheKey("resp_http_canceled_001"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &OpenAIGatewayService{}
|
||||
@@ -710,6 +741,23 @@ type stubGatewayCache struct {
|
||||
deletedSessions map[string]int
|
||||
}
|
||||
|
||||
type responseBindContextProbeCache struct {
|
||||
stubGatewayCache
|
||||
setContextErrors []error
|
||||
setDeadlines []time.Time
|
||||
}
|
||||
|
||||
func (c *responseBindContextProbeCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
c.setContextErrors = append(c.setContextErrors, ctx.Err())
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
c.setDeadlines = append(c.setDeadlines, deadline)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.stubGatewayCache.SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl)
|
||||
}
|
||||
|
||||
func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
if id, ok := c.sessionBindings[sessionHash]; ok {
|
||||
return id, nil
|
||||
|
||||
@@ -82,6 +82,43 @@ func isOpenAIOAuthServableModel(requestedModel string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// deepseekServableModels 列出 DeepSeek 平台账号在「未配置 model_mapping」时
|
||||
// 可服务的官方模型名(精确匹配,小写比较)。deepseek-flash 与 deepseek-v4-pro
|
||||
// 为官方现行名;deepseek-v4-flash / deepseek-v4-flash-vision-exp 为处于兼容
|
||||
// 路由期的旧名(上游仍接受并按 flash 价计费);deepseek-v4-pro-0813 为与
|
||||
// 计费 pro 档口径一致的版本化名。
|
||||
var deepseekServableModels = []string{
|
||||
"deepseek-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-flash-vision-exp",
|
||||
"deepseek-v4-pro-0813",
|
||||
}
|
||||
|
||||
// isDeepseekServableModel 报告 DeepSeek 平台账号在「未配置 model_mapping」时
|
||||
// 可服务的模型名。官方现行模型为 deepseek-flash 与 deepseek-v4-pro;旧名
|
||||
// deepseek-v4-flash / deepseek-v4-flash-vision-exp 处于兼容路由期(上游仍接受并
|
||||
// 按 flash 价计费),deepseek-v4-pro-0813 为版本化名。其余一律拒绝:既避免把
|
||||
// 未知模型透传给上游(上游 404/400 会触发 per-model 冷却并掩盖真实配置错误),
|
||||
// 也避免上游把不认识的模型名静默兜底成 deepseek-flash。
|
||||
// Source: https://api-docs.deepseek.com/quick_start/pricing
|
||||
//
|
||||
// https://api-docs.deepseek.com/guides/anthropic_api
|
||||
func isDeepseekServableModel(requestedModel string) bool {
|
||||
// 官方 Claude Code 接入文档要求 ANTHROPIC_MODEL=deepseek-flash[1m]:
|
||||
// [1m] 是客户端上下文选择器,先按同一规则归一化再比对白名单。
|
||||
model := strings.ToLower(normalizeClaudeCodeLongContextModel(strings.TrimSpace(requestedModel)))
|
||||
if model == "" {
|
||||
return true // 空模型交由上层必填校验处理
|
||||
}
|
||||
for _, servable := range deepseekServableModels {
|
||||
if model == servable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveOpenAICompactForwardModel determines the compact-only upstream model
|
||||
// for /responses/compact requests. It never affects normal /responses traffic.
|
||||
// When no compact-specific mapping matches, the input model is returned as-is.
|
||||
|
||||
@@ -429,6 +429,30 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
|
||||
model: "gpt-4.1",
|
||||
want: "gpt-4.1",
|
||||
},
|
||||
{
|
||||
name: "deepseek strips claude code long context suffix",
|
||||
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
|
||||
model: "deepseek-flash[1m]",
|
||||
want: "deepseek-flash",
|
||||
},
|
||||
{
|
||||
name: "deepseek strips duplicated long context suffix",
|
||||
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
|
||||
model: "deepseek-flash[1M][1m]",
|
||||
want: "deepseek-flash",
|
||||
},
|
||||
{
|
||||
name: "deepseek preserves plain model",
|
||||
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
|
||||
model: "deepseek-flash",
|
||||
want: "deepseek-flash",
|
||||
},
|
||||
{
|
||||
name: "non deepseek preserves long context suffix",
|
||||
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformOpenAI},
|
||||
model: "deepseek-flash[1m]",
|
||||
want: "deepseek-flash[1m]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenAIGatewayService implements service.PluginAccountDirectory for the OpenAI
|
||||
// OAuth outbound transport capability. The directory is intentionally scoped to
|
||||
// OpenAI OAuth-like, non-shadow accounts regardless of the requested filter, so a
|
||||
// plugin can never enumerate or resolve credentials outside that set. The host
|
||||
// additionally only wires this directory into plugins whose manifest declares the
|
||||
// matching capability (see PluginManager.buildHostServices).
|
||||
|
||||
// ListPluginAccounts returns the ids of active OpenAI OAuth-like accounts.
|
||||
func (s *OpenAIGatewayService) ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error) {
|
||||
if s == nil || s.accountRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if p := strings.TrimSpace(platform); p != "" && p != PlatformOpenAI {
|
||||
return nil, nil
|
||||
}
|
||||
if at := strings.TrimSpace(accountType); at != "" && at != AccountTypeOAuth {
|
||||
// Setup-token style accounts are OAuth-like but not AccountTypeOAuth; the
|
||||
// current binding only routes AccountTypeOAuth, so honour that filter.
|
||||
return nil, nil
|
||||
}
|
||||
accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
account := accounts[i]
|
||||
if account.Status == StatusActive && account.IsOpenAIOAuthLike() && !account.IsShadow() && account.Type == AccountTypeOAuth {
|
||||
ids = append(ids, account.ID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ResolvePluginOutboundIdentity resolves the access token plus the outbound
|
||||
// identity headers and proxy the host would attach to a live request for the
|
||||
// account. It returns (nil, nil) for out-of-scope accounts or when no token can
|
||||
// be resolved.
|
||||
func (s *OpenAIGatewayService) ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error) {
|
||||
if s == nil || s.accountRepo == nil || accountID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil || account.Type != AccountTypeOAuth || !account.IsOpenAIOAuthLike() || account.IsShadow() {
|
||||
return nil, nil
|
||||
}
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
headers := http.Header{}
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ensureCodexIdentityHeaders(headers)
|
||||
enforceCodexIdentityHeaders(headers)
|
||||
return &PluginOutboundIdentity{
|
||||
AccountID: account.ID,
|
||||
Platform: account.Platform,
|
||||
AccountType: account.Type,
|
||||
ProxyURL: resolveAccountProxyURL(account),
|
||||
Token: token,
|
||||
Headers: headers,
|
||||
}, nil
|
||||
}
|
||||
@@ -354,8 +354,9 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
|
||||
message = "upstream request failed"
|
||||
}
|
||||
event := map[string]any{
|
||||
"type": "error",
|
||||
"status": statusCode,
|
||||
"type": "error",
|
||||
"sequence_number": 0,
|
||||
"status": statusCode,
|
||||
"error": map[string]any{
|
||||
"type": "upstream_error",
|
||||
"message": message,
|
||||
@@ -363,7 +364,7 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
|
||||
}
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return []byte(`{"type":"error","error":{"type":"upstream_error","message":"upstream request failed"}}`)
|
||||
return []byte(`{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":"upstream request failed"}}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -398,9 +399,9 @@ func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte,
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
response["model"] = model
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"type": "response.failed", "response": response})
|
||||
body, err := json.Marshal(map[string]any{"type": "response.failed", "sequence_number": 0, "response": response})
|
||||
if err != nil {
|
||||
return []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
|
||||
return []byte(`{"type":"response.failed","sequence_number":0,"response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// 通用宿主服务(HostService)的资源上限。这些限制与任何具体插件能力无关,
|
||||
// 只用于约束单个插件对共享存储的占用,属于防御性护栏而非业务策略。
|
||||
const (
|
||||
// pluginKVMaxPluginKeyLen 与清单 id 的 maxLength 一致(manifest.schema.json),
|
||||
// 确保任何可安装插件的 pluginKey 都能通过校验,不会被误判为不可用。
|
||||
pluginKVMaxPluginKeyLen = 160
|
||||
pluginKVMaxNamespaceLen = 128
|
||||
pluginKVMaxKeyLen = 256
|
||||
pluginKVMaxValueBytes = 256 * 1024
|
||||
pluginKVMaxListLimit = 1000
|
||||
pluginKVDefaultListLimit = 100
|
||||
pluginKVMaxTTL = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// PluginKVStore 是宿主向插件提供的通用命名空间键值存储端口。它对存储介质保持中立
|
||||
// (由 repository 层用 Redis 等实现),供任何插件持久化跨请求 / 跨副本 / 跨重启的
|
||||
// 状态。pluginKey 由宿主根据服务该连接的运行时注入,插件无法伪造,从而保证不同
|
||||
// 插件之间命名空间严格隔离。
|
||||
type PluginKVStore interface {
|
||||
Get(ctx context.Context, pluginKey, namespace, key string) ([]byte, bool, error)
|
||||
Set(ctx context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error
|
||||
Delete(ctx context.Context, pluginKey, namespace, key string) error
|
||||
List(ctx context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error)
|
||||
}
|
||||
|
||||
// PluginOutboundIdentity 是宿主为某账号解析出的、可直接用于出站请求的身份材料:
|
||||
// 访问令牌、宿主会附加的出站请求头,以及账号代理。
|
||||
type PluginOutboundIdentity struct {
|
||||
AccountID int64
|
||||
Platform string
|
||||
AccountType string
|
||||
ProxyURL string
|
||||
Token string
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
// PluginAccountDirectory 让插件枚举其能力所覆盖的账号,并按需解析这些账号的出站身份,
|
||||
// 无需等待一条真实请求流经插件。这是一项敏感能力(会把账号凭据交给插件进程),因此
|
||||
// 宿主只对「其声明能力确实覆盖这些账号」的插件开放(见 PluginManager.buildHostServices)。
|
||||
// 实现方自身也必须把返回范围收敛到该能力对应的账号集合。
|
||||
type PluginAccountDirectory interface {
|
||||
ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error)
|
||||
ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error)
|
||||
}
|
||||
|
||||
// pluginHostServiceServer 实现 pluginv1.HostServiceServer,是宿主经 go-plugin broker
|
||||
// 反向暴露给单个插件进程的服务端点。它绑定到具体插件的 pluginKey,因此每个运行时都有
|
||||
// 自己的实例;所有键值操作都被强制限定在该插件的命名空间内。
|
||||
type pluginHostServiceServer struct {
|
||||
pluginv1.UnimplementedHostServiceServer
|
||||
pluginKey string
|
||||
store PluginKVStore
|
||||
directory PluginAccountDirectory
|
||||
}
|
||||
|
||||
func newPluginHostServiceServer(pluginKey string, store PluginKVStore, directory PluginAccountDirectory) *pluginHostServiceServer {
|
||||
return &pluginHostServiceServer{pluginKey: pluginKey, store: store, directory: directory}
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) ready() bool {
|
||||
return s != nil && s.store != nil && isValidPluginKVSegment(s.pluginKey, pluginKVMaxPluginKeyLen)
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) KVGet(ctx context.Context, req *pluginv1.KVGetRequest) (*pluginv1.KVGetResponse, error) {
|
||||
if !s.ready() {
|
||||
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "请求为空")
|
||||
}
|
||||
if err := validatePluginKVNamespace(req.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePluginKVKey(req.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, found, err := s.store.Get(ctx, s.pluginKey, req.Namespace, req.Key)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "读取键值失败: %v", err)
|
||||
}
|
||||
if !found {
|
||||
return &pluginv1.KVGetResponse{Found: false}, nil
|
||||
}
|
||||
return &pluginv1.KVGetResponse{Found: true, Value: value}, nil
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) KVSet(ctx context.Context, req *pluginv1.KVSetRequest) (*pluginv1.KVSetResponse, error) {
|
||||
if !s.ready() {
|
||||
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "请求为空")
|
||||
}
|
||||
if err := validatePluginKVNamespace(req.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePluginKVKey(req.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.Value) > pluginKVMaxValueBytes {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "值超过 %d 字节上限", pluginKVMaxValueBytes)
|
||||
}
|
||||
ttl, err := pluginKVTTL(req.TtlSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.Set(ctx, s.pluginKey, req.Namespace, req.Key, req.Value, ttl); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "写入键值失败: %v", err)
|
||||
}
|
||||
return &pluginv1.KVSetResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) KVDelete(ctx context.Context, req *pluginv1.KVDeleteRequest) (*pluginv1.KVDeleteResponse, error) {
|
||||
if !s.ready() {
|
||||
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "请求为空")
|
||||
}
|
||||
if err := validatePluginKVNamespace(req.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePluginKVKey(req.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.Delete(ctx, s.pluginKey, req.Namespace, req.Key); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "删除键值失败: %v", err)
|
||||
}
|
||||
return &pluginv1.KVDeleteResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) KVList(ctx context.Context, req *pluginv1.KVListRequest) (*pluginv1.KVListResponse, error) {
|
||||
if !s.ready() {
|
||||
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "请求为空")
|
||||
}
|
||||
if err := validatePluginKVNamespace(req.Namespace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.KeyPrefix != "" {
|
||||
if err := validatePluginKVKey(req.KeyPrefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
limit := int(req.Limit)
|
||||
if limit <= 0 {
|
||||
limit = pluginKVDefaultListLimit
|
||||
}
|
||||
if limit > pluginKVMaxListLimit {
|
||||
limit = pluginKVMaxListLimit
|
||||
}
|
||||
keys, err := s.store.List(ctx, s.pluginKey, req.Namespace, req.KeyPrefix, limit)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "列举键值失败: %v", err)
|
||||
}
|
||||
return &pluginv1.KVListResponse{Keys: keys}, nil
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) ListAccounts(ctx context.Context, req *pluginv1.ListAccountsRequest) (*pluginv1.ListAccountsResponse, error) {
|
||||
if s == nil || s.directory == nil {
|
||||
return nil, status.Error(codes.Unavailable, "账号目录不可用")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "请求为空")
|
||||
}
|
||||
ids, err := s.directory.ListPluginAccounts(ctx, req.Platform, req.AccountType)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "列举账号失败: %v", err)
|
||||
}
|
||||
return &pluginv1.ListAccountsResponse{AccountIds: ids}, nil
|
||||
}
|
||||
|
||||
func (s *pluginHostServiceServer) ResolveOutboundIdentity(ctx context.Context, req *pluginv1.ResolveOutboundIdentityRequest) (*pluginv1.ResolveOutboundIdentityResponse, error) {
|
||||
if s == nil || s.directory == nil {
|
||||
return nil, status.Error(codes.Unavailable, "账号目录不可用")
|
||||
}
|
||||
if req == nil || req.AccountId <= 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "account_id 无效")
|
||||
}
|
||||
identity, err := s.directory.ResolvePluginOutboundIdentity(ctx, req.AccountId)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "解析账号出站身份失败: %v", err)
|
||||
}
|
||||
if identity == nil {
|
||||
return &pluginv1.ResolveOutboundIdentityResponse{Found: false}, nil
|
||||
}
|
||||
return &pluginv1.ResolveOutboundIdentityResponse{
|
||||
Found: true,
|
||||
AccountId: identity.AccountID,
|
||||
Platform: identity.Platform,
|
||||
AccountType: identity.AccountType,
|
||||
ProxyUrl: identity.ProxyURL,
|
||||
Token: identity.Token,
|
||||
Headers: headersToPlugin(identity.Headers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pluginKVTTL(seconds int64) (time.Duration, error) {
|
||||
if seconds < 0 {
|
||||
return 0, status.Error(codes.InvalidArgument, "ttl_seconds 不能为负")
|
||||
}
|
||||
if seconds == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// 先在整数秒上比较上限,避免 seconds*time.Second 溢出 int64 后回绕成负值、
|
||||
// 从而绕过上限检查把负 TTL 传给 Redis。
|
||||
maxSeconds := int64(pluginKVMaxTTL / time.Second)
|
||||
if seconds > maxSeconds {
|
||||
return 0, status.Errorf(codes.InvalidArgument, "ttl_seconds 超过上限 %d", maxSeconds)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
|
||||
func validatePluginKVNamespace(namespace string) error {
|
||||
if namespace == "" {
|
||||
return status.Error(codes.InvalidArgument, "namespace 不能为空")
|
||||
}
|
||||
if !isValidPluginKVSegment(namespace, pluginKVMaxNamespaceLen) {
|
||||
return status.Error(codes.InvalidArgument, "namespace 仅允许字母、数字、'.'、'_'、'-' 且长度受限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginKVKey(key string) error {
|
||||
if key == "" {
|
||||
return status.Error(codes.InvalidArgument, "key 不能为空")
|
||||
}
|
||||
if !isValidPluginKVSegment(key, pluginKVMaxKeyLen) {
|
||||
return status.Error(codes.InvalidArgument, "key 仅允许字母、数字、'.'、'_'、'-' 且长度受限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidPluginKVSegment 限定命名段字符集为 [A-Za-z0-9._-]。这既避免 Redis glob
|
||||
// 元字符(*?[])污染 SCAN,也避免 ':' 破坏内部键结构,从而保证命名空间隔离可靠。
|
||||
func isValidPluginKVSegment(value string, maxLen int) bool {
|
||||
if value == "" || len(value) > maxLen {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '.' || r == '_' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// brokerProbePlugin 是仅用于测试的插件传输实现:它实现 HostBrokerReceiver 以拿到
|
||||
// broker,并在 InitHostServices 中用宿主下发的 broker id 拨号回宿主的 HostService,
|
||||
// 走一次真实 gRPC 的 KVSet/KVGet 往返。
|
||||
type brokerProbePlugin struct {
|
||||
pluginv1.UnimplementedTransportPluginServer
|
||||
broker *hcplugin.GRPCBroker
|
||||
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
getFound bool
|
||||
getValue []byte
|
||||
failure string
|
||||
}
|
||||
|
||||
func (p *brokerProbePlugin) SetHostBroker(broker *hcplugin.GRPCBroker) { p.broker = broker }
|
||||
|
||||
func (p *brokerProbePlugin) InitHostServices(ctx context.Context, req *pluginv1.InitHostServicesRequest) (*pluginv1.InitHostServicesResponse, error) {
|
||||
if p.broker == nil {
|
||||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: "broker 未注入"}, nil
|
||||
}
|
||||
conn, err := p.broker.Dial(req.HostServiceId)
|
||||
if err != nil {
|
||||
p.record("dial: " + err.Error())
|
||||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
client := pluginv1.NewHostServiceClient(conn)
|
||||
if _, err := client.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "probe", Value: []byte("hello"), TtlSeconds: 60}); err != nil {
|
||||
p.record("set: " + err.Error())
|
||||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||||
}
|
||||
got, err := client.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "probe"})
|
||||
if err != nil {
|
||||
p.record("get: " + err.Error())
|
||||
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.ready = true
|
||||
p.getFound = got.Found
|
||||
p.getValue = got.Value
|
||||
p.mu.Unlock()
|
||||
return &pluginv1.InitHostServicesResponse{Ready: true}, nil
|
||||
}
|
||||
|
||||
func (p *brokerProbePlugin) record(failure string) {
|
||||
p.mu.Lock()
|
||||
p.failure = failure
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// noHostServicesPlugin 模拟老插件:不实现 InitHostServices(返回 Unimplemented),
|
||||
// 也不接收 broker。
|
||||
type noHostServicesPlugin struct {
|
||||
pluginv1.UnimplementedTransportPluginServer
|
||||
}
|
||||
|
||||
func dispenseTransportClient(t *testing.T, impl pluginv1.TransportPluginServer) *pluginv1.TransportClient {
|
||||
t.Helper()
|
||||
client, _ := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
|
||||
pluginv1.TransportPluginName: &pluginv1.GRPCPlugin{Impl: impl},
|
||||
})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
dispensed, err := client.Dispense(pluginv1.TransportPluginName)
|
||||
require.NoError(t, err)
|
||||
tc, ok := dispensed.(*pluginv1.TransportClient)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, tc.Broker)
|
||||
return tc
|
||||
}
|
||||
|
||||
// 端到端验证 broker 反向通道:宿主服务 AcceptAndServe + 插件 Dial 回来,经真实 gRPC
|
||||
// 完成 KV 往返,且写入落到宿主注入的 pluginKey 命名空间下。
|
||||
func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) {
|
||||
probe := &brokerProbePlugin{}
|
||||
tc := dispenseTransportClient(t, probe)
|
||||
|
||||
store := newFakePluginKVStore()
|
||||
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
|
||||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
|
||||
|
||||
probe.mu.Lock()
|
||||
defer probe.mu.Unlock()
|
||||
require.Empty(t, probe.failure)
|
||||
require.True(t, probe.ready)
|
||||
assert.True(t, probe.getFound)
|
||||
assert.Equal(t, []byte("hello"), probe.getValue)
|
||||
|
||||
stored, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, []byte("hello"), stored)
|
||||
}
|
||||
|
||||
// 老插件不实现 InitHostServices 时,offerPluginHostServices 必须优雅降级、不 panic、
|
||||
// 不写入任何状态。
|
||||
func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) {
|
||||
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
|
||||
|
||||
store := newFakePluginKVStore()
|
||||
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
|
||||
require.NotPanics(t, func() {
|
||||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
|
||||
})
|
||||
|
||||
_, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
// hostServices 为 nil(未配置键值存储)时不得触发任何 broker 交互。
|
||||
func TestOfferPluginHostServices_NilHostServicesNoop(t *testing.T) {
|
||||
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
|
||||
require.NotPanics(t, func() {
|
||||
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// fakePluginKVStore 是内存版 PluginKVStore,按 (pluginKey, namespace, key) 三元组隔离,
|
||||
// 用于确定性地验证宿主服务端的校验与命名空间隔离逻辑。
|
||||
type fakePluginKVStore struct {
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
ttls map[string]time.Duration
|
||||
}
|
||||
|
||||
func newFakePluginKVStore() *fakePluginKVStore {
|
||||
return &fakePluginKVStore{values: map[string][]byte{}, ttls: map[string]time.Duration{}}
|
||||
}
|
||||
|
||||
func (f *fakePluginKVStore) compose(pluginKey, namespace, key string) string {
|
||||
return pluginKey + "\x00" + namespace + "\x00" + key
|
||||
}
|
||||
|
||||
func (f *fakePluginKVStore) Get(_ context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
value, ok := f.values[f.compose(pluginKey, namespace, key)]
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
return append([]byte(nil), value...), true, nil
|
||||
}
|
||||
|
||||
func (f *fakePluginKVStore) Set(_ context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
composed := f.compose(pluginKey, namespace, key)
|
||||
f.values[composed] = append([]byte(nil), value...)
|
||||
f.ttls[composed] = ttl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePluginKVStore) Delete(_ context.Context, pluginKey, namespace, key string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
composed := f.compose(pluginKey, namespace, key)
|
||||
delete(f.values, composed)
|
||||
delete(f.ttls, composed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePluginKVStore) List(_ context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
prefix := f.compose(pluginKey, namespace, keyPrefix)
|
||||
scope := f.compose(pluginKey, namespace, "")
|
||||
var keys []string
|
||||
for composed := range f.values {
|
||||
if strings.HasPrefix(composed, prefix) {
|
||||
keys = append(keys, strings.TrimPrefix(composed, scope))
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if limit > 0 && len(keys) > limit {
|
||||
keys = keys[:limit]
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_SetGetDeleteRoundtrip(t *testing.T) {
|
||||
store := newFakePluginKVStore()
|
||||
server := newPluginHostServiceServer("local.example.plugin", store, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "alpha", Value: []byte("v1"), TtlSeconds: 60})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.Found)
|
||||
assert.Equal(t, []byte("v1"), got.Value)
|
||||
assert.Equal(t, time.Minute, store.ttls[store.compose("local.example.plugin", "state", "alpha")])
|
||||
|
||||
_, err = server.KVDelete(ctx, &pluginv1.KVDeleteRequest{Namespace: "state", Key: "alpha"})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err = server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Found)
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_GetNotFound(t *testing.T) {
|
||||
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
|
||||
got, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "missing"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Found)
|
||||
assert.Nil(t, got.Value)
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_NamespaceIsolationAcrossPlugins(t *testing.T) {
|
||||
store := newFakePluginKVStore()
|
||||
first := newPluginHostServiceServer("local.plugin.one", store, nil)
|
||||
second := newPluginHostServiceServer("local.plugin.two", store, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := first.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "shared", Value: []byte("first")})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 另一个插件即便使用完全相同的 namespace/key 也读不到,因为 pluginKey 由宿主注入。
|
||||
got, err := second.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Found)
|
||||
|
||||
got, err = first.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.Found)
|
||||
assert.Equal(t, []byte("first"), got.Value)
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_ListPrefixAndLimit(t *testing.T) {
|
||||
store := newFakePluginKVStore()
|
||||
server := newPluginHostServiceServer("local.example.plugin", store, nil)
|
||||
ctx := context.Background()
|
||||
for _, key := range []string{"account-1", "account-2", "account-3", "other-1"} {
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: key, Value: []byte("x")})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
resp, err := server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-"})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3"}, resp.Keys)
|
||||
|
||||
resp, err = server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-", Limit: 2})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resp.Keys, 2)
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_Validation(t *testing.T) {
|
||||
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
|
||||
ctx := context.Background()
|
||||
oversized := make([]byte, pluginKVMaxValueBytes+1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"empty namespace", func() error {
|
||||
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "", Key: "k"})
|
||||
return err
|
||||
}},
|
||||
{"namespace with colon", func() error {
|
||||
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "a:b", Key: "k"})
|
||||
return err
|
||||
}},
|
||||
{"key with glob", func() error {
|
||||
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "a*"})
|
||||
return err
|
||||
}},
|
||||
{"empty key", func() error {
|
||||
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: ""})
|
||||
return err
|
||||
}},
|
||||
{"oversized value", func() error {
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: oversized})
|
||||
return err
|
||||
}},
|
||||
{"negative ttl", func() error {
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: -1})
|
||||
return err
|
||||
}},
|
||||
{"ttl over max", func() error {
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: int64(pluginKVMaxTTL/time.Second) + 1})
|
||||
return err
|
||||
}},
|
||||
{"ttl overflow", func() error {
|
||||
// 溢出用例:seconds*time.Second 会回绕成负值,必须仍判为超限而非放行。
|
||||
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: math.MaxInt64})
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.call()
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_UnavailableWhenNoStore(t *testing.T) {
|
||||
server := newPluginHostServiceServer("local.example.plugin", nil, nil)
|
||||
_, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
|
||||
// pluginKey 为空同样视为不可用,避免键前缀塌缩导致的跨插件泄漏。
|
||||
blank := newPluginHostServiceServer("", newFakePluginKVStore(), nil)
|
||||
_, err = blank.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
|
||||
// pluginKey 含 ':' 会破坏内部键结构,必须判定为不可用而非放行。
|
||||
tainted := newPluginHostServiceServer("bad:key", newFakePluginKVStore(), nil)
|
||||
_, err = tainted.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
}
|
||||
|
||||
// pluginKey 的长度上限必须覆盖清单 id 的 maxLength(160),否则长 id 的合法插件会被
|
||||
// 静默剥夺宿主服务能力。
|
||||
func TestPluginHostServiceServer_PluginKeyLengthBoundary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
maxLen := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen), newFakePluginKVStore(), nil)
|
||||
_, err := maxLen.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
|
||||
require.NoError(t, err)
|
||||
|
||||
tooLong := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen+1), newFakePluginKVStore(), nil)
|
||||
_, err = tooLong.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
}
|
||||
|
||||
type fakeAccountDirectory struct {
|
||||
ids []int64
|
||||
identity *PluginOutboundIdentity
|
||||
lastReq int64
|
||||
}
|
||||
|
||||
func (f *fakeAccountDirectory) ListPluginAccounts(_ context.Context, _, _ string) ([]int64, error) {
|
||||
return f.ids, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context, accountID int64) (*PluginOutboundIdentity, error) {
|
||||
f.lastReq = accountID
|
||||
if f.identity == nil || f.identity.AccountID != accountID {
|
||||
return nil, nil
|
||||
}
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
func TestPluginHostServiceServer_AccountDirectory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := &fakeAccountDirectory{
|
||||
ids: []int64{3, 7},
|
||||
identity: &PluginOutboundIdentity{
|
||||
AccountID: 7, Platform: "openai", AccountType: "oauth", ProxyURL: "http://p:1",
|
||||
Token: "tok", Headers: http.Header{"Originator": {"codex-tui"}},
|
||||
},
|
||||
}
|
||||
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), dir)
|
||||
|
||||
list, err := server.ListAccounts(ctx, &pluginv1.ListAccountsRequest{Platform: "openai", AccountType: "oauth"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int64{3, 7}, list.AccountIds)
|
||||
|
||||
resolved, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 7})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resolved.Found)
|
||||
assert.Equal(t, "tok", resolved.Token)
|
||||
assert.Equal(t, "http://p:1", resolved.ProxyUrl)
|
||||
assert.Equal(t, "codex-tui", resolved.Headers["Originator"].Values[0])
|
||||
|
||||
// unknown account → found=false, no error
|
||||
miss, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 99})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, miss.Found)
|
||||
|
||||
// invalid account id → InvalidArgument
|
||||
_, err = server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 0})
|
||||
assert.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
|
||||
// 无目录时账号目录 RPC 必须返回 Unavailable(KV 仍可用),保证未授权插件拿不到凭据。
|
||||
func TestPluginHostServiceServer_DirectoryUnavailableWithoutDirectory(t *testing.T) {
|
||||
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
|
||||
_, err := server.ListAccounts(context.Background(), &pluginv1.ListAccountsRequest{})
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
_, err = server.ResolveOutboundIdentity(context.Background(), &pluginv1.ResolveOutboundIdentityRequest{AccountId: 1})
|
||||
assert.Equal(t, codes.Unavailable, status.Code(err))
|
||||
// KV 仍可用
|
||||
_, kvErr := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
|
||||
require.NoError(t, kvErr)
|
||||
}
|
||||
|
||||
func TestPluginDeclaresOpenAIOAuthCapability(t *testing.T) {
|
||||
match := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}
|
||||
if !pluginDeclaresOpenAIOAuthCapability(match) {
|
||||
t.Fatal("matching capability must be recognized")
|
||||
}
|
||||
wrongType := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: "apikey"}}}
|
||||
if pluginDeclaresOpenAIOAuthCapability(wrongType) {
|
||||
t.Fatal("wrong account_type must not match")
|
||||
}
|
||||
empty := PluginManifest{}
|
||||
if pluginDeclaresOpenAIOAuthCapability(empty) {
|
||||
t.Fatal("no capability must not match")
|
||||
}
|
||||
}
|
||||
|
||||
// buildHostServices 只对声明了 OpenAI OAuth 能力的插件注入账号目录。
|
||||
func TestBuildHostServicesGatesDirectoryByCapability(t *testing.T) {
|
||||
dir := &fakeAccountDirectory{}
|
||||
m := &PluginManager{kvStore: newFakePluginKVStore(), accountDirectory: dir}
|
||||
|
||||
authorized := &PluginInstallation{PluginKey: "p.authorized", Manifest: PluginManifest{
|
||||
Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}},
|
||||
}}
|
||||
srv, ok := m.buildHostServices(authorized).(*pluginHostServiceServer)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, srv.directory)
|
||||
|
||||
unauthorized := &PluginInstallation{PluginKey: "p.other", Manifest: PluginManifest{
|
||||
Capabilities: []PluginCapability{{ID: "some.other.capability", Platform: "x", AccountType: "y"}},
|
||||
}}
|
||||
srv2, ok := m.buildHostServices(unauthorized).(*pluginHostServiceServer)
|
||||
require.True(t, ok)
|
||||
require.Nil(t, srv2.directory, "unauthorized plugin must not receive the account directory")
|
||||
require.NotNil(t, srv2.store, "but KV remains available")
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type PluginManager struct {
|
||||
cfg *config.Config
|
||||
hostInfo PluginHostInfo
|
||||
installer *PluginPackageInstaller
|
||||
// kvStore 为运行中的插件提供通用宿主键值存储;为 nil 时不向插件暴露宿主服务。
|
||||
kvStore PluginKVStore
|
||||
// accountDirectory 为声明了对应能力的插件提供账号目录与出站身份解析(敏感能力);
|
||||
// 通过 SetAccountDirectory 在启动装配阶段注入,为 nil 时插件拿不到该能力。
|
||||
accountDirectory PluginAccountDirectory
|
||||
|
||||
operationMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
@@ -57,13 +62,14 @@ type PluginManager struct {
|
||||
route atomic.Pointer[pluginRoute]
|
||||
}
|
||||
|
||||
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager {
|
||||
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore) *PluginManager {
|
||||
return &PluginManager{
|
||||
repo: repo,
|
||||
encryptor: encryptor,
|
||||
cfg: cfg,
|
||||
hostInfo: hostInfo,
|
||||
installer: NewPluginPackageInstaller(cfg, hostInfo),
|
||||
kvStore: kvStore,
|
||||
runtimes: make(map[int64]*pluginRuntime),
|
||||
localInstallations: make(map[int64]*PluginInstallation),
|
||||
}
|
||||
@@ -813,6 +819,26 @@ func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfi
|
||||
return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON})
|
||||
}
|
||||
|
||||
// Status returns the plugin's passive runtime status (its Health response, including
|
||||
// any status_json blob) for the config UI. Unlike Test it never applies config,
|
||||
// starts a temporary runtime, or reaches upstream — a plugin that is not currently
|
||||
// running simply reports "not running" with no status blob. This makes it safe to
|
||||
// serve from a lightweight, ungated, read-only endpoint used for status polling.
|
||||
func (m *PluginManager) Status(ctx context.Context, id int64) (*pluginv1.HealthResponse, error) {
|
||||
if _, err := m.repo.GetByID(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
runtime := m.runtimes[id]
|
||||
m.mu.Unlock()
|
||||
if runtime == nil {
|
||||
return &pluginv1.HealthResponse{Healthy: false, Message: "插件未运行"}, nil
|
||||
}
|
||||
statusCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return runtime.status(statusCtx)
|
||||
}
|
||||
|
||||
type pluginUIAssetClaims struct {
|
||||
Version int `json:"version"`
|
||||
PluginID int64 `json:"plugin_id"`
|
||||
@@ -1018,7 +1044,44 @@ func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInst
|
||||
return nil, err
|
||||
}
|
||||
timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
|
||||
return startPluginRuntime(ctx, installation, timeout, socketDir)
|
||||
return startPluginRuntime(ctx, installation, timeout, socketDir, m.buildHostServices(installation))
|
||||
}
|
||||
|
||||
// SetAccountDirectory 注入账号目录实现(敏感能力)。仅在启动装配阶段调用一次,
|
||||
// 早于 Start,因此运行期读取无需额外同步。
|
||||
func (m *PluginManager) SetAccountDirectory(directory PluginAccountDirectory) {
|
||||
m.mu.Lock()
|
||||
m.accountDirectory = directory
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// buildHostServices 为单个插件构造绑定其 pluginKey 的宿主服务端点。返回 nil(未配置
|
||||
// 键值存储或缺少 pluginKey)时,startPluginRuntime 不会向插件暴露任何宿主服务。
|
||||
// 账号目录(会向插件交付账号凭据)只对「清单声明了 OpenAI OAuth 出站能力」的插件开放,
|
||||
// 从而把凭据暴露面收敛到本就要处理这些账号的插件。
|
||||
func (m *PluginManager) buildHostServices(installation *PluginInstallation) pluginv1.HostServiceServer {
|
||||
if m.kvStore == nil || installation == nil || strings.TrimSpace(installation.PluginKey) == "" {
|
||||
return nil
|
||||
}
|
||||
var directory PluginAccountDirectory
|
||||
if pluginDeclaresOpenAIOAuthCapability(installation.Manifest) {
|
||||
m.mu.Lock()
|
||||
directory = m.accountDirectory
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory)
|
||||
}
|
||||
|
||||
// pluginDeclaresOpenAIOAuthCapability reports whether the (install-validated)
|
||||
// manifest declares the OpenAI OAuth outbound transport capability.
|
||||
func pluginDeclaresOpenAIOAuthCapability(manifest PluginManifest) bool {
|
||||
for _, capability := range manifest.Capabilities {
|
||||
if capability.ID == PluginCapabilityOpenAIOAuthOutbound &&
|
||||
capability.Platform == PlatformOpenAI && capability.AccountType == AccountTypeOAuth {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime {
|
||||
|
||||
@@ -112,3 +112,24 @@ func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) {
|
||||
assert.Less(t, first, uint64(100))
|
||||
}
|
||||
}
|
||||
|
||||
type statusStubRepository struct {
|
||||
PluginRepository
|
||||
}
|
||||
|
||||
func (r *statusStubRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
|
||||
return &PluginInstallation{ID: 7}, nil
|
||||
}
|
||||
|
||||
// Status is the read-only, ungated runtime-status channel: when the plugin is not
|
||||
// running it must report "not running" with no status blob and never error or start
|
||||
// a runtime (that side-effecting behaviour belongs to Test, not Status).
|
||||
func TestPluginManagerStatusReportsNotRunningWithoutRuntime(t *testing.T) {
|
||||
manager := &PluginManager{repo: &statusStubRepository{}}
|
||||
resp, err := manager.Status(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.False(t, resp.Healthy)
|
||||
assert.Equal(t, "插件未运行", resp.Message)
|
||||
assert.Empty(t, resp.StatusJson)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -19,6 +20,9 @@ import (
|
||||
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
|
||||
hclog "github.com/hashicorp/go-hclog"
|
||||
hcplugin "github.com/hashicorp/go-plugin"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type pluginRuntime struct {
|
||||
@@ -31,7 +35,7 @@ type pluginRuntime struct {
|
||||
doneOnce sync.Once
|
||||
}
|
||||
|
||||
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) {
|
||||
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string, hostServices pluginv1.HostServiceServer) (*pluginRuntime, error) {
|
||||
if installation == nil {
|
||||
return nil, errors.New("插件安装记录为空")
|
||||
}
|
||||
@@ -66,11 +70,12 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s
|
||||
client.Kill()
|
||||
return nil, fmt.Errorf("获取插件传输能力: %w", err)
|
||||
}
|
||||
api, ok := dispensed.(pluginv1.TransportPluginClient)
|
||||
if !ok {
|
||||
transportClient, ok := dispensed.(*pluginv1.TransportClient)
|
||||
if !ok || transportClient.TransportPluginClient == nil {
|
||||
client.Kill()
|
||||
return nil, errors.New("插件未实现传输 gRPC 客户端")
|
||||
}
|
||||
api := transportClient.TransportPluginClient
|
||||
runtime := &pluginRuntime{
|
||||
installation: installation,
|
||||
client: client,
|
||||
@@ -97,9 +102,56 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s
|
||||
}
|
||||
return nil, fmt.Errorf("插件不健康: %s", health.Message)
|
||||
}
|
||||
// 可选地把宿主服务(HostService)反向暴露给插件。这是叠加在传输契约之上的能力:
|
||||
// 老插件不实现 InitHostServices(返回 Unimplemented),此处静默跳过,绝不阻断启动。
|
||||
offerPluginHostServices(ctx, installation, api, transportClient.Broker, hostServices, startTimeout)
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// offerPluginHostServices 在 go-plugin broker 上启动一个宿主服务实例,并通过
|
||||
// InitHostServices 把 broker 流 id 交给插件。服务生命周期与插件进程绑定:client.Kill()
|
||||
// 会关闭 broker,AcceptAndServe 随之 GracefulStop,无需手动清理。整个过程尽力而为,
|
||||
// 任何失败都只记录日志、不影响插件转发能力。
|
||||
func offerPluginHostServices(
|
||||
ctx context.Context,
|
||||
installation *PluginInstallation,
|
||||
api pluginv1.TransportPluginClient,
|
||||
broker *hcplugin.GRPCBroker,
|
||||
hostServices pluginv1.HostServiceServer,
|
||||
startTimeout time.Duration,
|
||||
) {
|
||||
if broker == nil || hostServices == nil || api == nil {
|
||||
return
|
||||
}
|
||||
brokerID := broker.NextId()
|
||||
go broker.AcceptAndServe(brokerID, func(opts []grpc.ServerOption) *grpc.Server {
|
||||
server := grpc.NewServer(opts...)
|
||||
pluginv1.RegisterHostServiceServer(server, hostServices)
|
||||
return server
|
||||
})
|
||||
initCtx, cancel := context.WithTimeout(ctx, startTimeout)
|
||||
defer cancel()
|
||||
resp, err := api.InitHostServices(initCtx, &pluginv1.InitHostServicesRequest{
|
||||
HostServiceId: brokerID,
|
||||
HostServiceApiVersion: pluginv1.HostServiceAPIVersion,
|
||||
})
|
||||
pluginKey := ""
|
||||
if installation != nil {
|
||||
pluginKey = installation.PluginKey
|
||||
}
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.Unimplemented {
|
||||
slog.Debug("plugin_host_services_unimplemented", "plugin", pluginKey)
|
||||
} else {
|
||||
slog.Warn("plugin_host_services_init_failed", "plugin", pluginKey, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if resp != nil && !resp.Ready {
|
||||
slog.Debug("plugin_host_services_declined", "plugin", pluginKey, "message", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error {
|
||||
_, err := r.validateAndApplyNormalizedConfig(ctx, configJSON)
|
||||
return err
|
||||
@@ -161,6 +213,23 @@ func (r *pluginRuntime) checkHealth(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// status returns the plugin's passive Health response, including any status_json
|
||||
// blob it exposes for the config UI. It performs no config apply and no upstream
|
||||
// call, so it is safe to serve from a lightweight, ungated status endpoint.
|
||||
func (r *pluginRuntime) status(ctx context.Context) (*pluginv1.HealthResponse, error) {
|
||||
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
|
||||
return nil, errors.New("插件进程已退出")
|
||||
}
|
||||
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("插件状态查询失败: %w", err)
|
||||
}
|
||||
if health == nil {
|
||||
return nil, errors.New("插件未返回状态")
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (r *pluginRuntime) beginRequest() bool {
|
||||
if r == nil || r.draining.Load() {
|
||||
return false
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestPluginRuntimeIntegration(t *testing.T) {
|
||||
assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative)))
|
||||
}
|
||||
|
||||
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"))
|
||||
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"), nil)
|
||||
require.NoError(t, err)
|
||||
defer runtime.kill()
|
||||
require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
|
||||
@@ -35,22 +37,65 @@ func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool
|
||||
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
|
||||
}
|
||||
|
||||
// cnQuotaExhausted403ErrorType 是 Kimi Coding Plan 配额窗口耗尽的 403 错误类型。
|
||||
// 与并发限制 403(见 isCNProviderConcurrencyLimit403)不同,这是 5h/weekly
|
||||
// 窗口耗尽的限流信号:窗口到期后自动恢复,绝不能落入通用 403 升级计数
|
||||
// (连续 3 次永久 SetError),必须按 429 口径冷却到真实窗口重置点。
|
||||
const cnQuotaExhausted403ErrorType = "access_terminated_error"
|
||||
|
||||
// cnQuotaExhaustedReasonPrefix 是配额耗尽 403 临时停调 reason 的稳定前缀。
|
||||
const cnQuotaExhaustedReasonPrefix = "cn_quota_exhausted"
|
||||
|
||||
// isCNProviderQuotaExhausted403 识别 CN 供应商配额窗口耗尽的 403。
|
||||
// 先做廉价的文案子串匹配(usage limit / quota will reset),未命中再解析
|
||||
// 响应体匹配结构化错误类型(Kimi 的 access_terminated_error),以兼容同族
|
||||
// 供应商的文案变体。与并发限制文案互斥,两类信号不会误判。
|
||||
// 仅限 Coding Plan 账号:滚动窗口快照(含重置时间)只有 Coding Plan 账号才有,
|
||||
// 非 Coding Plan 账号的同类 403 语义不明(可能是需要升级套餐的硬上限),
|
||||
// 应回退到通用 403 升级逻辑而非无限临时冷却。
|
||||
func isCNProviderQuotaExhausted403(account *Account, responseBody []byte, upstreamMsg string) bool {
|
||||
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(upstreamMsg))
|
||||
if strings.Contains(msg, "usage limit") || strings.Contains(msg, "quota will reset") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String()), cnQuotaExhausted403ErrorType)
|
||||
}
|
||||
|
||||
// handleCNProviderQuotaExhausted403 把 CN 供应商配额窗口耗尽的 403 按 429
|
||||
// 同口径处理:有配额快照时冷却到最早的未来窗口重置点(SetRateLimited,
|
||||
// 窗口过期后调度自动恢复);快照缺失(额度探测尚未刷新)时兜底默认 403
|
||||
// 冷却时长的临时停调,避免过度停调到数天后的窗口。无论哪条路径都不写
|
||||
// 账号 error 状态。
|
||||
func (s *RateLimitService) handleCNProviderQuotaExhausted403(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
upstreamMsg string,
|
||||
) {
|
||||
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, cnQuotaExhaustedReasonPrefix, "cn_quota_exhausted_rate_limited") != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// upstreamMsg 由调用方(HandleUpstreamError)从同一响应体提取,此处直接复用;
|
||||
// 为空时仅存前缀,保证 reason 稳定可检索。
|
||||
reason := cnQuotaExhaustedReasonPrefix
|
||||
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
|
||||
reason += ": " + msg
|
||||
}
|
||||
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
|
||||
s.setCNProviderTempUnschedulable(ctx, account, until, cnQuotaExhaustedReasonPrefix, reason, "cn_quota_exhausted_temp_unschedulable")
|
||||
}
|
||||
|
||||
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
) {
|
||||
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
|
||||
reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
|
||||
s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cn_provider_concurrency_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"until", until.UTC(),
|
||||
)
|
||||
s.setCNProviderTempUnschedulable(ctx, account, until,
|
||||
cnConcurrencyLimitReasonPrefix, cnConcurrencyLimitReasonPrefix+": "+kimiConcurrentRequestLimitMessage,
|
||||
"cn_provider_concurrency_limited")
|
||||
}
|
||||
|
||||
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
|
||||
@@ -151,6 +196,45 @@ func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
|
||||
return earliest
|
||||
}
|
||||
|
||||
// cooldownCNProviderToQuotaSnapshotReset 把账号冷却到配额快照中最早的未来窗口
|
||||
// 重置点(SetRateLimited,窗口过期后调度自动恢复),供 429(OpenCodeGo /
|
||||
// Coding Plan)与配额耗尽 403 共用。仅在冷却成功持久化后返回非 nil;
|
||||
// 无快照或写库失败时返回 nil,由调用方落入各自的兜底逻辑(避免写库失败
|
||||
// 时账号完全失去持久冷却)。
|
||||
func (s *RateLimitService) cooldownCNProviderToQuotaSnapshotReset(ctx context.Context, account *Account, reason, logEvent string) *time.Time {
|
||||
until := cnProviderQuotaSnapshotReset(account, time.Now())
|
||||
if until == nil {
|
||||
return nil
|
||||
}
|
||||
s.notifyAccountSchedulingBlocked(account, *until, reason)
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return nil
|
||||
}
|
||||
slog.Info(logEvent,
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", until.UTC(),
|
||||
)
|
||||
return until
|
||||
}
|
||||
|
||||
// setCNProviderTempUnschedulable 写临时停调(含调度层通知与日志),供 CN 403
|
||||
// 各分支(并发限制、配额耗尽兜底)共用。告警 key 由 notifyReason 派生,保持
|
||||
// 各分支既有日志 key 稳定(<prefix>_set_temp_unschedulable_failed)。
|
||||
func (s *RateLimitService) setCNProviderTempUnschedulable(ctx context.Context, account *Account, until time.Time, notifyReason, storeReason, logEvent string) {
|
||||
s.notifyAccountSchedulingBlocked(account, until, notifyReason)
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, storeReason); err != nil {
|
||||
slog.Warn(notifyReason+"_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info(logEvent,
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"until", until.UTC(),
|
||||
)
|
||||
}
|
||||
|
||||
// applyCNProviderReactive429 处理国产供应商的 429 响应。
|
||||
// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
|
||||
func (s *RateLimitService) applyCNProviderReactive429(
|
||||
@@ -160,17 +244,7 @@ func (s *RateLimitService) applyCNProviderReactive429(
|
||||
responseBody []byte,
|
||||
) bool {
|
||||
if account.IsOpenCodeGo() {
|
||||
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
|
||||
s.notifyAccountSchedulingBlocked(account, *until, "429")
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("opencode_go_rate_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", *until,
|
||||
)
|
||||
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "opencode_go_rate_limited") != nil {
|
||||
return true
|
||||
}
|
||||
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
|
||||
@@ -199,20 +273,9 @@ func (s *RateLimitService) applyCNProviderReactive429(
|
||||
}
|
||||
// 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
|
||||
// cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
|
||||
if account.IsCodingPlan() {
|
||||
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
|
||||
s.notifyAccountSchedulingBlocked(account, *until, "429")
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("cn_coding_plan_rate_limited",
|
||||
"account_id", account.ID,
|
||||
"platform", account.Platform,
|
||||
"reset_at", *until,
|
||||
)
|
||||
return true
|
||||
}
|
||||
if account.IsCodingPlan() &&
|
||||
s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "cn_coding_plan_rate_limited") != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -992,6 +992,14 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
|
||||
s.handleCNProviderConcurrencyLimit403(ctx, account)
|
||||
return true
|
||||
}
|
||||
// Kimi 等 CN 供应商把 Coding Plan 配额窗口耗尽打成 403
|
||||
// (error.type=access_terminated_error),这是窗口到期后自动恢复的限流
|
||||
// 信号而非封禁:按 429 口径冷却到真实窗口重置点,避免落入下方通用 403
|
||||
// 升级计数后被永久 SetError。
|
||||
if isCNProviderQuotaExhausted403(account, responseBody, upstreamMsg) {
|
||||
s.handleCNProviderQuotaExhausted403(ctx, account, upstreamMsg)
|
||||
return true
|
||||
}
|
||||
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
|
||||
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
|
||||
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
|
||||
|
||||
@@ -17,6 +17,7 @@ type rateLimitAccountRepoStub struct {
|
||||
mockAccountRepoForGemini
|
||||
setErrorCalls int
|
||||
tempCalls int
|
||||
rateLimitedCalls int
|
||||
updateCredentialsCalls int
|
||||
updateExtraCalls int
|
||||
lastCredentials map[string]any
|
||||
@@ -25,6 +26,8 @@ type rateLimitAccountRepoStub struct {
|
||||
lastTempReason string
|
||||
lastErrorID int64
|
||||
lastTempID int64
|
||||
lastRateLimitedID int64
|
||||
lastRateLimitedAt time.Time
|
||||
tempErr error
|
||||
}
|
||||
|
||||
@@ -42,6 +45,13 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
|
||||
return r.tempErr
|
||||
}
|
||||
|
||||
func (r *rateLimitAccountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||
r.rateLimitedCalls++
|
||||
r.lastRateLimitedID = id
|
||||
r.lastRateLimitedAt = resetAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
|
||||
r.updateCredentialsCalls++
|
||||
r.lastCredentials = shallowCopyMap(credentials)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newCNQuotaExhaustedTestAccount 构造一个 Kimi Coding Plan 账号:
|
||||
// account_mode=coding 使 IsCodingPlan() 为真,Extra 带 5h/weekly 窗口快照。
|
||||
func newCNQuotaExhaustedTestAccount(resetIn time.Duration) *Account {
|
||||
extra := map[string]any{}
|
||||
if resetIn > 0 {
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)] = 100
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hReset)] = time.Now().Add(resetIn).UTC().Format(time.RFC3339)
|
||||
extra[cnExtraKey(PlatformKimi, cnExtraSuffixWeeklyReset)] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return &Account{
|
||||
ID: 401,
|
||||
Platform: PlatformKimi,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"account_mode": AccountModeCoding},
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
const kimiWeeklyQuotaExhaustedBody = `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/membership/subscription?tab=quota","type":"access_terminated_error"}}`
|
||||
|
||||
// runKimi403 以 403 + 给定响应体驱动 HandleUpstreamError,返回 shouldDisable。
|
||||
func runKimi403(service *RateLimitService, account *Account, body string) bool {
|
||||
return service.HandleUpstreamError(
|
||||
context.Background(),
|
||||
account,
|
||||
http.StatusForbidden,
|
||||
http.Header{},
|
||||
[]byte(body),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403RateLimitedToWindowReset(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
blocker := &runtimeBlockRecorder{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetAccountRuntimeBlocker(blocker)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls, "quota exhaustion must never SetError")
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
require.Equal(t, 1, repo.rateLimitedCalls)
|
||||
require.Equal(t, account.ID, repo.lastRateLimitedID)
|
||||
require.True(t, repo.lastRateLimitedAt.After(time.Now()), "cooldown should target the future window reset")
|
||||
require.True(t, repo.lastRateLimitedAt.Before(time.Now().Add(3*time.Hour)), "should cool down to the nearer 5h reset, not weekly")
|
||||
require.Len(t, blocker.accounts, 1)
|
||||
require.Equal(t, cnQuotaExhaustedReasonPrefix, blocker.reasons[0])
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403WithoutSnapshotFallsBackToTemp(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(0)
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, cnQuotaExhaustedReasonPrefix)
|
||||
require.Greater(t, len(repo.lastTempReason), len(cnQuotaExhaustedReasonPrefix))
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiQuotaExhausted403MatchedByMessageOnly(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
// 无 error.type 字段,仅靠文案兜底匹配。
|
||||
body := `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}`
|
||||
|
||||
shouldDisable := runKimi403(service, account, body)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 1, repo.rateLimitedCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiConcurrencyLimit403StillUsesConcurrencyPath(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
body := fmt.Sprintf(`{"error":{"message":%q,"type":"access_terminated_error"}}`, kimiConcurrentRequestLimitMessage)
|
||||
|
||||
shouldDisable := runKimi403(service, account, body)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls, "concurrency-limit message must keep its dedicated path")
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiGeneric403StillEscalates(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{3}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
|
||||
shouldDisable := runKimi403(service, account, `{"error":{"message":"policy rejected this request"}}`)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls, "non-quota 403 keeps the generic escalation path")
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_KimiNonCodingPlanQuotaMessageUsesGeneric403(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{3}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
|
||||
account.Credentials = map[string]any{"account_mode": AccountModePayG}
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 1, repo.setErrorCalls, "non-coding-plan account must keep the generic 403 escalation")
|
||||
require.Equal(t, 0, repo.rateLimitedCalls)
|
||||
require.Equal(t, 0, repo.tempCalls)
|
||||
}
|
||||
|
||||
func TestHandleUpstreamError_NonCNAccessTerminated403KeepsGenericPath(t *testing.T) {
|
||||
repo := &rateLimitAccountRepoStub{}
|
||||
counter := &openAI403CounterCacheStub{counts: []int64{1}}
|
||||
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
service.SetOpenAI403CounterCache(counter)
|
||||
account := &Account{
|
||||
ID: 402,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
|
||||
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
require.Equal(t, 0, repo.rateLimitedCalls, "non-CN platform must not enter the CN quota path")
|
||||
require.Equal(t, 0, repo.setErrorCalls)
|
||||
require.Equal(t, 1, repo.tempCalls)
|
||||
require.Contains(t, repo.lastTempReason, "(1/3)")
|
||||
}
|
||||
|
||||
func TestIsCNProviderQuotaExhausted403_Classification(t *testing.T) {
|
||||
kimi := newCNQuotaExhaustedTestAccount(0)
|
||||
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"You've reached your weekly (7-day) usage limit."}}`), "You've reached your weekly (7-day) usage limit."))
|
||||
require.False(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"policy rejected"}}`), "policy rejected"))
|
||||
require.False(t, isCNProviderQuotaExhausted403(kimi, nil, ""))
|
||||
|
||||
openai := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
require.False(t, isCNProviderQuotaExhausted403(openai, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
require.False(t, isCNProviderQuotaExhausted403(nil, []byte(kimiWeeklyQuotaExhaustedBody), ""))
|
||||
}
|
||||
@@ -742,3 +742,8 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID,
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserHistoryPaginated returns all redemption types for the authenticated user.
|
||||
func (s *RedeemService) GetUserHistoryPaginated(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
|
||||
return s.redeemRepo.ListByUserPaginated(ctx, userID, params, "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create"
|
||||
SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status"
|
||||
SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete"
|
||||
OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance"
|
||||
)
|
||||
|
||||
func (e GrokMediaEndpoint) IsSeedance() bool {
|
||||
return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
|
||||
}
|
||||
|
||||
// SeedanceTaskKey isolates ownership and billing keys from other video providers.
|
||||
func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) }
|
||||
|
||||
func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) {
|
||||
var info GrokMediaRequestInfo
|
||||
if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() {
|
||||
return info, fmt.Errorf("request body must be a JSON object")
|
||||
}
|
||||
model := gjson.GetBytes(body, "model")
|
||||
if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" {
|
||||
return info, fmt.Errorf("model is required")
|
||||
}
|
||||
content := gjson.GetBytes(body, "content")
|
||||
if !content.IsArray() || len(content.Array()) == 0 {
|
||||
return info, fmt.Errorf("content must be a non-empty array")
|
||||
}
|
||||
info.Model = strings.TrimSpace(model.String())
|
||||
var texts []string
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
texts = append(texts, item.Get("text").String())
|
||||
case "image_url":
|
||||
info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String())
|
||||
}
|
||||
}
|
||||
info.Prompt = strings.Join(texts, "\n")
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) {
|
||||
base = strings.TrimRight(base, "/")
|
||||
// Accept an origin, a proxy prefix, or the full Ark API base.
|
||||
if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") {
|
||||
base += "/api/v3"
|
||||
}
|
||||
base += "/contents/generations/tasks"
|
||||
if endpoint != SeedanceEndpointCreate {
|
||||
if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" {
|
||||
return "", fmt.Errorf("invalid Seedance task ID")
|
||||
}
|
||||
base += "/" + taskID
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// ForwardSeedance preserves the Ark protocol, including multimodal content and
|
||||
// future fields. Only model is rewritten using the account's configured mapping.
|
||||
func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) {
|
||||
if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() {
|
||||
return nil, fmt.Errorf("seedance requires an OpenAI API key account with a custom base URL")
|
||||
}
|
||||
base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model, upstreamModel := "", ""
|
||||
method := http.MethodGet
|
||||
switch endpoint {
|
||||
case SeedanceEndpointCreate:
|
||||
info, parseErr := ParseSeedanceRequest(body)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
model = info.Model
|
||||
upstreamModel = account.GetMappedModel(model)
|
||||
body, err = sjson.SetBytes(body, "model", upstreamModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method = http.MethodPost
|
||||
case SeedanceEndpointDelete:
|
||||
method = http.MethodDelete
|
||||
}
|
||||
token := strings.TrimSpace(account.GetCredential("api_key"))
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("seedance account missing api_key")
|
||||
}
|
||||
started := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
proxy := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxy = account.Proxy.URL()
|
||||
}
|
||||
resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Do not retry ambiguous asynchronous creates: the upstream may already have
|
||||
// accepted a billable job. Preserve native error codes and response bodies.
|
||||
if resp.StatusCode >= 300 {
|
||||
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
|
||||
return nil, fmt.Errorf("seedance upstream status %d", resp.StatusCode)
|
||||
}
|
||||
result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()}
|
||||
if endpoint == SeedanceEndpointCreate {
|
||||
id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String())
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("seedance create response missing task ID")
|
||||
}
|
||||
result.ResponseID = SeedanceTaskKey(id)
|
||||
}
|
||||
if endpoint == SeedanceEndpointStatus {
|
||||
result.ResponseID = taskID
|
||||
result.UpstreamModel = gjson.GetBytes(responseBody, "model").String()
|
||||
if gjson.GetBytes(responseBody, "status").String() == "succeeded" {
|
||||
result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int()))
|
||||
}
|
||||
}
|
||||
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func seedanceTestAccount() *Account {
|
||||
return &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{
|
||||
"api_key": "ark-secret", "base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"openai_capabilities": []string{"seedance"},
|
||||
"model_mapping": map[string]any{"video": "ep-seedance"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestSeedanceNativeForwarding(t *testing.T) {
|
||||
body := []byte(`{"model":"video","content":[{"type":"text","text":"waves"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"audio_url","audio_url":{"url":"https://example.com/audio.mp3"}}],"duration":-1,"generate_audio":true,"future_field":{"keep":true}}`)
|
||||
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"id":"task-1"}`)}
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
|
||||
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"id":"task-1"}`, w.Body.String())
|
||||
require.Equal(t, "seedance:task-1", result.ResponseID)
|
||||
require.Zero(t, result.Usage.OutputTokens)
|
||||
require.Equal(t, "video", result.BillingModel)
|
||||
require.Equal(t, "ep-seedance", result.UpstreamModel)
|
||||
require.Equal(t, "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", upstream.request.URL.String())
|
||||
require.Equal(t, "Bearer ark-secret", upstream.request.Header.Get("Authorization"))
|
||||
forwarded, err := io.ReadAll(upstream.request.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ep-seedance", gjson.GetBytes(forwarded, "model").String())
|
||||
for _, field := range []string{"content", "duration", "generate_audio", "future_field"} {
|
||||
require.Equal(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(forwarded, field).Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedanceStatusAndDelete(t *testing.T) {
|
||||
for _, status := range []string{"queued", "running", "failed", "cancelled", "expired", "succeeded"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
body := `{"id":"task-1","status":"` + status + `","model":"ep-seedance","content":{"video_url":"https://cdn.example/video.mp4"},"usage":{"completion_tokens":12345}}`
|
||||
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(body)}
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
c, w := grokMediaContentTestContext(http.MethodGet, "/api/v3/contents/generations/tasks/task-1", nil)
|
||||
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointStatus, "seedance:task-1", nil)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, body, w.Body.String())
|
||||
require.Equal(t, "/api/v3/contents/generations/tasks/task-1", upstream.request.URL.Path)
|
||||
if status == "succeeded" {
|
||||
require.Equal(t, 12345, result.Usage.OutputTokens)
|
||||
} else {
|
||||
require.Zero(t, result.Usage.OutputTokens)
|
||||
}
|
||||
require.Zero(t, result.VideoCount, "must use token billing, not Grok seconds")
|
||||
})
|
||||
}
|
||||
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse("")}
|
||||
upstream.response.StatusCode = http.StatusNoContent
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
c, w := grokMediaContentTestContext(http.MethodDelete, "/api/v3/contents/generations/tasks/task-1", nil)
|
||||
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointDelete, "seedance:task-1", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodDelete, upstream.request.Method)
|
||||
require.Equal(t, http.StatusNoContent, w.Code)
|
||||
}
|
||||
|
||||
func TestSeedanceValidationAndCapability(t *testing.T) {
|
||||
for _, body := range []string{`{`, `[]`, `{}`, `{"model":12,"content":[{}]}`, `{"model":"x","content":[]}`} {
|
||||
_, err := ParseSeedanceRequest([]byte(body))
|
||||
require.Error(t, err)
|
||||
}
|
||||
info, err := ParseSeedanceRequest([]byte(`{"model":"x","content":[{"type":"text","text":"first"},{"type":"text","text":"second"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(info.ModerationBody()), "first")
|
||||
require.Contains(t, string(info.ModerationBody()), "second")
|
||||
require.Contains(t, string(info.ModerationBody()), "https://example.com/a.png")
|
||||
for _, id := range []string{"", "..", "a/b", "a?b", "a#b", "%2e%2e"} {
|
||||
_, err := buildSeedanceURL("https://example.com", SeedanceEndpointStatus, id)
|
||||
require.Error(t, err, id)
|
||||
}
|
||||
for _, base := range []string{"https://example.com", "https://example.com/api/v3/", "https://example.com/v3"} {
|
||||
url, err := buildSeedanceURL(base, SeedanceEndpointCreate, "")
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, url, "/v3/api/v3")
|
||||
}
|
||||
a := seedanceTestAccount()
|
||||
require.True(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
|
||||
a.Type = AccountTypeOAuth
|
||||
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
|
||||
a.Type = AccountTypeAPIKey
|
||||
delete(a.Credentials, "openai_capabilities")
|
||||
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
|
||||
}
|
||||
|
||||
func TestSeedancePreservesUpstreamErrorsWithoutRetry(t *testing.T) {
|
||||
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"error":{"code":"QuotaExceeded","message":"quota exhausted"}}`)}
|
||||
upstream.response.StatusCode = 429
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
|
||||
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", []byte(`{"model":"video","content":[{"type":"text","text":"waves"}]}`))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, 429, w.Code)
|
||||
require.Contains(t, w.Body.String(), "QuotaExceeded")
|
||||
require.Len(t, upstream.requests, 1)
|
||||
}
|
||||
@@ -51,7 +51,6 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont
|
||||
}
|
||||
}
|
||||
if options.ActiveOnly && account.Status != StatusActive ||
|
||||
!account.Schedulable ||
|
||||
account.Type != AccountTypeOAuth ||
|
||||
!platformAllowed ||
|
||||
options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" ||
|
||||
@@ -176,7 +175,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: false,
|
||||
Credentials: map[string]any{"refresh_token": "permanently-rejected-token"},
|
||||
Credentials: map[string]any{"refresh_token": "paused-account-token"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -195,7 +194,9 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
|
||||
svc.processRefresh()
|
||||
|
||||
require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query")
|
||||
require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs)
|
||||
// Account 7 is paused (schedulable=false) but active: it must still be
|
||||
// refreshed so its stored access_token does not silently expire.
|
||||
require.ElementsMatch(t, []int64{1, 6, 7}, repo.updatedCredentialIDs)
|
||||
require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state")
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,21 @@
|
||||
- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。
|
||||
- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。
|
||||
|
||||
## 宿主服务(HostService)
|
||||
|
||||
`HostService` 是宿主经 go-plugin broker 反向暴露给插件的通用能力层,独立于具体插件类型,供有状态插件使用。它通过 `TransportPlugin.InitHostServices` 在运行时协商:宿主启动后把一个 broker 流 id 交给插件,插件用它拨号回宿主并获得 `HostServiceClient`。
|
||||
|
||||
- **可选且向后兼容**:未实现 `InitHostServices` 的旧插件返回 `Unimplemented`,宿主静默跳过,转发能力不受影响。宿主服务有独立的 `HostServiceAPIVersion`,新增能力不会改变传输契约版本,也不会使既有插件失效。
|
||||
- **随进程回收**:宿主服务实例与插件进程生命周期绑定,插件退出时 broker 关闭并自动 `GracefulStop`,无需插件手动清理。
|
||||
|
||||
当前提供的通用设施:
|
||||
|
||||
- **命名空间键值存储(KV)**:`KVGet` / `KVSet` / `KVDelete` / `KVList`,为插件持久化跨请求、跨副本、跨重启的状态。存储由 Redis 支撑,因此多实例部署天然共享同一份状态。
|
||||
- **命名空间隔离**:宿主根据服务该连接的运行时注入插件身份(`pluginKey`),插件无法伪造,也无法读写其它插件的命名空间。
|
||||
- **护栏**:`namespace` / `key` 仅允许 `[A-Za-z0-9._-]`;单值上限 256 KiB;`ttl_seconds` 为 0 表示不过期、正值有上限;`KVList` 返回条数有上限。
|
||||
|
||||
新增宿主设施时,在 `HostService` 上追加 RPC 即可,无需改动传输契约或清单格式。
|
||||
|
||||
## 包结构
|
||||
|
||||
```text
|
||||
@@ -61,15 +76,21 @@ ui/assets/...
|
||||
|
||||
插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。
|
||||
|
||||
UI 可以发送以下消息:
|
||||
UI 可以发送以下消息。消息按语义分层,鉴权与副作用一致对应(读操作免二次验证,写/主动测试需要二次验证),任何插件都可复用,不针对具体插件定制:
|
||||
|
||||
- `config.load`
|
||||
- `config.save`
|
||||
- `config.test`
|
||||
- `ui.resize`
|
||||
- `ui.notify`
|
||||
| 消息 | 语义 | 二次验证 | 映射的插件 RPC |
|
||||
| --- | --- | --- | --- |
|
||||
| `config.load` | 读取已保存配置 | 否 | 宿主数据库 |
|
||||
| `config.save` | 写入配置 | 是 | `ValidateConfig` + `ApplyConfig` |
|
||||
| `config.test` | 主动测试配置/连通性(可产生副作用) | 是 | `TestConfig` |
|
||||
| `plugin.status` | 读取运行时状态(无副作用) | 否 | `Health`(`status_json`) |
|
||||
| `ui.resize` / `ui.notify` | 仅 UI 交互 | — | — |
|
||||
|
||||
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
|
||||
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。
|
||||
|
||||
- 配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
|
||||
- `config.test` 的结果由插件 UI 自行展示(内联或经 `ui.notify`),宿主不再对成功结果强制弹出提示,避免插件把它当作轻量状态轮询时刷屏。
|
||||
- `plugin.status` 是通用的**只读**状态通道:宿主经 `GET /admin/plugins/:id/status` 调用运行中插件的 `Health`,返回 `{healthy, message, status_json}`。`status_json` 是插件自定义的**不透明** JSON 快照(宿主不解析、不参与健康判定),插件必须以无副作用方式生成(不得应用配置、访问上游或触发探测),因此该端点只读、免二次验证。插件未运行时返回 `healthy=false` 且不含 `status_json`。这样带状态面板的插件无需滥用 `config.test` 即可展示实时状态。
|
||||
|
||||
## 协议源码
|
||||
|
||||
|
||||
@@ -44,10 +44,13 @@ UI 到宿主:
|
||||
| `config.load` | 无 | `config` |
|
||||
| `config.save` | `config` 对象 | 规范化后的 `config` |
|
||||
| `config.test` | 无 | `result` |
|
||||
| `plugin.status` | 无 | `result`(`Health`:`{healthy, message, status_json}`) |
|
||||
| `ui.resize` | `height` | 无响应 |
|
||||
| `ui.notify` | `level`、`message` | 无响应 |
|
||||
|
||||
`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。
|
||||
`config.test` 在 v1 中测试已保存配置(需二次验证,可产生副作用)。UI 若要测试当前表单,应先调用 `config.save`。
|
||||
|
||||
`plugin.status` 是只读运行时状态通道:无副作用、免二次验证、不弹宿主提示,供状态面板轮询。它映射到插件 `Health`,`result.status_json` 是插件自定义的不透明 JSON 快照。带状态展示的插件应使用它,而不是把 `config.test` 当作状态轮询。
|
||||
|
||||
## 必须执行的校验
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,28 @@ service TransportPlugin {
|
||||
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
|
||||
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
|
||||
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
|
||||
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
|
||||
// back to reach the host-provided HostService. It is an optional, generic
|
||||
// lifecycle hook: plugins built against an older contract leave it
|
||||
// Unimplemented and the host simply runs without offering host services.
|
||||
rpc InitHostServices(InitHostServicesRequest) returns (InitHostServicesResponse);
|
||||
}
|
||||
|
||||
// HostService is implemented by the host and dialed by the plugin over the
|
||||
// go-plugin broker id delivered via InitHostServices. It exposes generic,
|
||||
// capability-neutral facilities that any plugin may use; new facilities are
|
||||
// added here as new RPCs without changing the transport contract version.
|
||||
service HostService {
|
||||
rpc KVGet(KVGetRequest) returns (KVGetResponse);
|
||||
rpc KVSet(KVSetRequest) returns (KVSetResponse);
|
||||
rpc KVDelete(KVDeleteRequest) returns (KVDeleteResponse);
|
||||
rpc KVList(KVListRequest) returns (KVListResponse);
|
||||
// Account directory: enumerate the accounts the plugin is bound to act on and
|
||||
// resolve the outbound identity (credentials/headers/proxy) the host would
|
||||
// attach to a live request. The host restricts both calls to accounts the
|
||||
// requesting plugin's capability already covers.
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
||||
rpc ResolveOutboundIdentity(ResolveOutboundIdentityRequest) returns (ResolveOutboundIdentityResponse);
|
||||
}
|
||||
|
||||
message GetInfoRequest {}
|
||||
@@ -28,6 +50,12 @@ message HealthRequest {}
|
||||
message HealthResponse {
|
||||
bool healthy = 1;
|
||||
string message = 2;
|
||||
// status_json is an optional plugin-defined JSON blob describing current runtime
|
||||
// state (e.g. a status summary for the config UI). It MUST be produced passively
|
||||
// — reading it must not apply config, reach upstream, or cause side effects — so
|
||||
// the host can expose it via a lightweight, ungated status endpoint. Ignored by
|
||||
// the host's health gating.
|
||||
string status_json = 3;
|
||||
}
|
||||
|
||||
message ValidateConfigRequest {
|
||||
@@ -57,6 +85,9 @@ message TestConfigResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
int64 latency_ms = 3;
|
||||
// status_json is an optional plugin-defined JSON blob surfaced to the config UI
|
||||
// alongside the test result (e.g. a runtime status summary). Ignored by the host.
|
||||
string status_json = 4;
|
||||
}
|
||||
|
||||
message HeaderValues {
|
||||
@@ -115,3 +146,79 @@ message ForwardResponse {
|
||||
ForwardResponseError error = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message InitHostServicesRequest {
|
||||
// host_service_id is the broker stream id the plugin dials to reach HostService.
|
||||
uint32 host_service_id = 1;
|
||||
// host_service_api_version lets the plugin reject a host it cannot talk to.
|
||||
uint32 host_service_api_version = 2;
|
||||
}
|
||||
|
||||
message InitHostServicesResponse {
|
||||
// ready is true once the plugin has successfully dialed back the host service.
|
||||
bool ready = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// KV messages back a namespaced, per-plugin persistent key-value store. The
|
||||
// host derives the owning plugin from the runtime that serves the connection,
|
||||
// so a plugin can never read or write another plugin's namespace.
|
||||
message KVGetRequest {
|
||||
string namespace = 1;
|
||||
string key = 2;
|
||||
}
|
||||
|
||||
message KVGetResponse {
|
||||
bool found = 1;
|
||||
bytes value = 2;
|
||||
}
|
||||
|
||||
message KVSetRequest {
|
||||
string namespace = 1;
|
||||
string key = 2;
|
||||
bytes value = 3;
|
||||
// ttl_seconds > 0 sets an expiry; 0 stores the entry without expiry.
|
||||
int64 ttl_seconds = 4;
|
||||
}
|
||||
|
||||
message KVSetResponse {}
|
||||
|
||||
message KVDeleteRequest {
|
||||
string namespace = 1;
|
||||
string key = 2;
|
||||
}
|
||||
|
||||
message KVDeleteResponse {}
|
||||
|
||||
message KVListRequest {
|
||||
string namespace = 1;
|
||||
string key_prefix = 2;
|
||||
int32 limit = 3;
|
||||
}
|
||||
|
||||
message KVListResponse {
|
||||
repeated string keys = 1;
|
||||
}
|
||||
|
||||
message ListAccountsRequest {
|
||||
string platform = 1;
|
||||
string account_type = 2;
|
||||
}
|
||||
|
||||
message ListAccountsResponse {
|
||||
repeated int64 account_ids = 1;
|
||||
}
|
||||
|
||||
message ResolveOutboundIdentityRequest {
|
||||
int64 account_id = 1;
|
||||
}
|
||||
|
||||
message ResolveOutboundIdentityResponse {
|
||||
bool found = 1;
|
||||
int64 account_id = 2;
|
||||
string platform = 3;
|
||||
string account_type = 4;
|
||||
string proxy_url = 5;
|
||||
string token = 6;
|
||||
map<string, HeaderValues> headers = 7;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
|
||||
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
|
||||
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
|
||||
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
|
||||
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
|
||||
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
|
||||
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
|
||||
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
|
||||
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
|
||||
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
|
||||
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
|
||||
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
|
||||
TransportPlugin_InitHostServices_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/InitHostServices"
|
||||
)
|
||||
|
||||
// TransportPluginClient is the client API for TransportPlugin service.
|
||||
@@ -37,6 +38,11 @@ type TransportPluginClient interface {
|
||||
ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error)
|
||||
TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error)
|
||||
Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error)
|
||||
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
|
||||
// back to reach the host-provided HostService. It is an optional, generic
|
||||
// lifecycle hook: plugins built against an older contract leave it
|
||||
// Unimplemented and the host simply runs without offering host services.
|
||||
InitHostServices(ctx context.Context, in *InitHostServicesRequest, opts ...grpc.CallOption) (*InitHostServicesResponse, error)
|
||||
}
|
||||
|
||||
type transportPluginClient struct {
|
||||
@@ -110,6 +116,16 @@ func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOp
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse]
|
||||
|
||||
func (c *transportPluginClient) InitHostServices(ctx context.Context, in *InitHostServicesRequest, opts ...grpc.CallOption) (*InitHostServicesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(InitHostServicesResponse)
|
||||
err := c.cc.Invoke(ctx, TransportPlugin_InitHostServices_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TransportPluginServer is the server API for TransportPlugin service.
|
||||
// All implementations must embed UnimplementedTransportPluginServer
|
||||
// for forward compatibility.
|
||||
@@ -120,6 +136,11 @@ type TransportPluginServer interface {
|
||||
ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error)
|
||||
TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error)
|
||||
Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error
|
||||
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
|
||||
// back to reach the host-provided HostService. It is an optional, generic
|
||||
// lifecycle hook: plugins built against an older contract leave it
|
||||
// Unimplemented and the host simply runs without offering host services.
|
||||
InitHostServices(context.Context, *InitHostServicesRequest) (*InitHostServicesResponse, error)
|
||||
mustEmbedUnimplementedTransportPluginServer()
|
||||
}
|
||||
|
||||
@@ -148,6 +169,9 @@ func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfi
|
||||
func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error {
|
||||
return status.Error(codes.Unimplemented, "method Forward not implemented")
|
||||
}
|
||||
func (UnimplementedTransportPluginServer) InitHostServices(context.Context, *InitHostServicesRequest) (*InitHostServicesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method InitHostServices not implemented")
|
||||
}
|
||||
func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {}
|
||||
func (UnimplementedTransportPluginServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -266,6 +290,24 @@ func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream)
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]
|
||||
|
||||
func _TransportPlugin_InitHostServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(InitHostServicesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(TransportPluginServer).InitHostServices(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: TransportPlugin_InitHostServices_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(TransportPluginServer).InitHostServices(ctx, req.(*InitHostServicesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -293,6 +335,10 @@ var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "TestConfig",
|
||||
Handler: _TransportPlugin_TestConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "InitHostServices",
|
||||
Handler: _TransportPlugin_InitHostServices_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
@@ -304,3 +350,313 @@ var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
|
||||
},
|
||||
Metadata: "plugin.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
HostService_KVGet_FullMethodName = "/sub2api.plugin.v1.HostService/KVGet"
|
||||
HostService_KVSet_FullMethodName = "/sub2api.plugin.v1.HostService/KVSet"
|
||||
HostService_KVDelete_FullMethodName = "/sub2api.plugin.v1.HostService/KVDelete"
|
||||
HostService_KVList_FullMethodName = "/sub2api.plugin.v1.HostService/KVList"
|
||||
HostService_ListAccounts_FullMethodName = "/sub2api.plugin.v1.HostService/ListAccounts"
|
||||
HostService_ResolveOutboundIdentity_FullMethodName = "/sub2api.plugin.v1.HostService/ResolveOutboundIdentity"
|
||||
)
|
||||
|
||||
// HostServiceClient is the client API for HostService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// HostService is implemented by the host and dialed by the plugin over the
|
||||
// go-plugin broker id delivered via InitHostServices. It exposes generic,
|
||||
// capability-neutral facilities that any plugin may use; new facilities are
|
||||
// added here as new RPCs without changing the transport contract version.
|
||||
type HostServiceClient interface {
|
||||
KVGet(ctx context.Context, in *KVGetRequest, opts ...grpc.CallOption) (*KVGetResponse, error)
|
||||
KVSet(ctx context.Context, in *KVSetRequest, opts ...grpc.CallOption) (*KVSetResponse, error)
|
||||
KVDelete(ctx context.Context, in *KVDeleteRequest, opts ...grpc.CallOption) (*KVDeleteResponse, error)
|
||||
KVList(ctx context.Context, in *KVListRequest, opts ...grpc.CallOption) (*KVListResponse, error)
|
||||
// Account directory: enumerate the accounts the plugin is bound to act on and
|
||||
// resolve the outbound identity (credentials/headers/proxy) the host would
|
||||
// attach to a live request. The host restricts both calls to accounts the
|
||||
// requesting plugin's capability already covers.
|
||||
ListAccounts(ctx context.Context, in *ListAccountsRequest, opts ...grpc.CallOption) (*ListAccountsResponse, error)
|
||||
ResolveOutboundIdentity(ctx context.Context, in *ResolveOutboundIdentityRequest, opts ...grpc.CallOption) (*ResolveOutboundIdentityResponse, error)
|
||||
}
|
||||
|
||||
type hostServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewHostServiceClient(cc grpc.ClientConnInterface) HostServiceClient {
|
||||
return &hostServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) KVGet(ctx context.Context, in *KVGetRequest, opts ...grpc.CallOption) (*KVGetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(KVGetResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_KVGet_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) KVSet(ctx context.Context, in *KVSetRequest, opts ...grpc.CallOption) (*KVSetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(KVSetResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_KVSet_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) KVDelete(ctx context.Context, in *KVDeleteRequest, opts ...grpc.CallOption) (*KVDeleteResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(KVDeleteResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_KVDelete_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) KVList(ctx context.Context, in *KVListRequest, opts ...grpc.CallOption) (*KVListResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(KVListResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_KVList_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) ListAccounts(ctx context.Context, in *ListAccountsRequest, opts ...grpc.CallOption) (*ListAccountsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListAccountsResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_ListAccounts_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *hostServiceClient) ResolveOutboundIdentity(ctx context.Context, in *ResolveOutboundIdentityRequest, opts ...grpc.CallOption) (*ResolveOutboundIdentityResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResolveOutboundIdentityResponse)
|
||||
err := c.cc.Invoke(ctx, HostService_ResolveOutboundIdentity_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HostServiceServer is the server API for HostService service.
|
||||
// All implementations must embed UnimplementedHostServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// HostService is implemented by the host and dialed by the plugin over the
|
||||
// go-plugin broker id delivered via InitHostServices. It exposes generic,
|
||||
// capability-neutral facilities that any plugin may use; new facilities are
|
||||
// added here as new RPCs without changing the transport contract version.
|
||||
type HostServiceServer interface {
|
||||
KVGet(context.Context, *KVGetRequest) (*KVGetResponse, error)
|
||||
KVSet(context.Context, *KVSetRequest) (*KVSetResponse, error)
|
||||
KVDelete(context.Context, *KVDeleteRequest) (*KVDeleteResponse, error)
|
||||
KVList(context.Context, *KVListRequest) (*KVListResponse, error)
|
||||
// Account directory: enumerate the accounts the plugin is bound to act on and
|
||||
// resolve the outbound identity (credentials/headers/proxy) the host would
|
||||
// attach to a live request. The host restricts both calls to accounts the
|
||||
// requesting plugin's capability already covers.
|
||||
ListAccounts(context.Context, *ListAccountsRequest) (*ListAccountsResponse, error)
|
||||
ResolveOutboundIdentity(context.Context, *ResolveOutboundIdentityRequest) (*ResolveOutboundIdentityResponse, error)
|
||||
mustEmbedUnimplementedHostServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedHostServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedHostServiceServer struct{}
|
||||
|
||||
func (UnimplementedHostServiceServer) KVGet(context.Context, *KVGetRequest) (*KVGetResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method KVGet not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) KVSet(context.Context, *KVSetRequest) (*KVSetResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method KVSet not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) KVDelete(context.Context, *KVDeleteRequest) (*KVDeleteResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method KVDelete not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) KVList(context.Context, *KVListRequest) (*KVListResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method KVList not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) ListAccounts(context.Context, *ListAccountsRequest) (*ListAccountsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListAccounts not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) ResolveOutboundIdentity(context.Context, *ResolveOutboundIdentityRequest) (*ResolveOutboundIdentityResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ResolveOutboundIdentity not implemented")
|
||||
}
|
||||
func (UnimplementedHostServiceServer) mustEmbedUnimplementedHostServiceServer() {}
|
||||
func (UnimplementedHostServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeHostServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to HostServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeHostServiceServer interface {
|
||||
mustEmbedUnimplementedHostServiceServer()
|
||||
}
|
||||
|
||||
func RegisterHostServiceServer(s grpc.ServiceRegistrar, srv HostServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedHostServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&HostService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _HostService_KVGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(KVGetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).KVGet(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_KVGet_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).KVGet(ctx, req.(*KVGetRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HostService_KVSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(KVSetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).KVSet(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_KVSet_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).KVSet(ctx, req.(*KVSetRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HostService_KVDelete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(KVDeleteRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).KVDelete(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_KVDelete_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).KVDelete(ctx, req.(*KVDeleteRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HostService_KVList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(KVListRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).KVList(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_KVList_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).KVList(ctx, req.(*KVListRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HostService_ListAccounts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListAccountsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).ListAccounts(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_ListAccounts_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).ListAccounts(ctx, req.(*ListAccountsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _HostService_ResolveOutboundIdentity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResolveOutboundIdentityRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(HostServiceServer).ResolveOutboundIdentity(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: HostService_ResolveOutboundIdentity_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(HostServiceServer).ResolveOutboundIdentity(ctx, req.(*ResolveOutboundIdentityRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// HostService_ServiceDesc is the grpc.ServiceDesc for HostService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var HostService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "sub2api.plugin.v1.HostService",
|
||||
HandlerType: (*HostServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "KVGet",
|
||||
Handler: _HostService_KVGet_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "KVSet",
|
||||
Handler: _HostService_KVSet_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "KVDelete",
|
||||
Handler: _HostService_KVDelete_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "KVList",
|
||||
Handler: _HostService_KVList_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListAccounts",
|
||||
Handler: _HostService_ListAccounts_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResolveOutboundIdentity",
|
||||
Handler: _HostService_ResolveOutboundIdentity_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "plugin.proto",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user