mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
236
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29eead917f | ||
|
|
1a9d49e16f | ||
|
|
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 | ||
|
|
325213ae66 | ||
|
|
0a215c9318 | ||
|
|
2061508ad8 | ||
|
|
fb90414c05 | ||
|
|
9fade3e8c2 | ||
|
|
1920a7d687 | ||
|
|
a444551a6d | ||
|
|
0bba06d64a | ||
|
|
150c52b8de | ||
|
|
3e4b6a09b2 | ||
|
|
dd2e6b3680 | ||
|
|
d4439129a7 | ||
|
|
1e1d15cca5 | ||
|
|
f7e959ae5f | ||
|
|
008391f699 | ||
|
|
a4c517917b | ||
|
|
e9169901d2 | ||
|
|
13c1d4cde2 | ||
|
|
087987070b | ||
|
|
4bd7132ba9 | ||
|
|
28a4ea914d | ||
|
|
5ecfaa4837 | ||
|
|
06af9f871c | ||
|
|
52558381c6 | ||
|
|
e3cce574d7 | ||
|
|
3d6c207723 | ||
|
|
a1d5968b24 | ||
|
|
e5272c130c | ||
|
|
196c15b5cd | ||
|
|
62635532a6 | ||
|
|
bc8da78150 | ||
|
|
db76cc4e4e | ||
|
|
d2c2b46c9e | ||
|
|
7e152e4279 | ||
|
|
bdb42e22f8 | ||
|
|
9383e0b154 | ||
|
|
c7ed614240 | ||
|
|
a59f0c7fac | ||
|
|
0a378f3432 | ||
|
|
f2b51e3b24 | ||
|
|
329641a86f | ||
|
|
f0dd497780 | ||
|
|
99b93b2985 | ||
|
|
67845665d6 | ||
|
|
3a070ec1bf | ||
|
|
749cd7c354 | ||
|
|
eaa4083e23 | ||
|
|
3873215908 | ||
|
|
2a4f3d1a71 | ||
|
|
4ff3e6dfb9 | ||
|
|
f78c4b241e | ||
|
|
e67ffda7ae | ||
|
|
d2067668d3 | ||
|
|
ba57ea914b | ||
|
|
5948988aae | ||
|
|
76b3f3c7cf | ||
|
|
db15ddf07f | ||
|
|
5d5b395d00 | ||
|
|
cf3aca6e3b | ||
|
|
956f4672e4 | ||
|
|
6206ce940e | ||
|
|
5968fd0ed9 | ||
|
|
c6727ed4c9 | ||
|
|
bb0b50d8bc | ||
|
|
429d6f0480 | ||
|
|
5bafffed51 | ||
|
|
781a02aea7 | ||
|
|
3645ce5f43 | ||
|
|
7cf90c79d6 | ||
|
|
195a331322 | ||
|
|
29371b0819 | ||
|
|
8e7954438d | ||
|
|
a65d476f25 | ||
|
|
961d9e94f3 | ||
|
|
51c5d03888 | ||
|
|
9b6a49b8e7 | ||
|
|
213de0797e | ||
|
|
d8326fccfc | ||
|
|
9eb120dd40 | ||
|
|
4726bdd08b | ||
|
|
8ea4dc56f0 | ||
|
|
23eef9ecc3 | ||
|
|
cdb5cfaf6c | ||
|
|
dfa83fbe93 | ||
|
|
2dff7af0f9 | ||
|
|
1067e89fab | ||
|
|
04c45e9dc2 | ||
|
|
5ee436d8a2 | ||
|
|
e5e04de137 | ||
|
|
fa565eafb1 | ||
|
|
eacff0a629 | ||
|
|
c0d5119375 | ||
|
|
22dffa1bb9 | ||
|
|
efcc2252e5 | ||
|
|
981279c996 | ||
|
|
c3b3072a6f | ||
|
|
489968fd7a | ||
|
|
7c008bd8c1 | ||
|
|
9c30951e4a | ||
|
|
40cf3f50a6 | ||
|
|
75b7dd1e0b | ||
|
|
642d20b8ac | ||
|
|
623c32e391 | ||
|
|
310f8b7fa2 | ||
|
|
6254aba5e9 | ||
|
|
7145484fde | ||
|
|
fd300ab6f3 | ||
|
|
88011a6a2e | ||
|
|
14029e50aa | ||
|
|
3fb03cdac1 | ||
|
|
8f9a9a255b | ||
|
|
bae37a00fb | ||
|
|
4543ddc5c1 | ||
|
|
613722eee4 | ||
|
|
66a10d4939 | ||
|
|
d0ca057ca4 | ||
|
|
e4c369bd56 | ||
|
|
1035a4daf0 | ||
|
|
242907854d | ||
|
|
8f2cba0c20 | ||
|
|
6feefb7aec | ||
|
|
9d475f9edb | ||
|
|
ab9bd9e877 | ||
|
|
b112805ffa | ||
|
|
8c56eabcdc | ||
|
|
d3bd614ba7 | ||
|
|
e9f5add97c | ||
|
|
ab3b398b5f | ||
|
|
5032d06697 | ||
|
|
bed1e3c362 | ||
|
|
a5b07b2960 | ||
|
|
df8eceba0c | ||
|
|
95a04c8dff | ||
|
|
cd1ee1d1ad | ||
|
|
9e7c2d713c | ||
|
|
06b61ba96f | ||
|
|
4a4b50d807 | ||
|
|
1ed36679bd | ||
|
|
44bc47a3e3 | ||
|
|
29a36a10be | ||
|
|
ef891643e9 | ||
|
|
7e62731863 | ||
|
|
140e172951 | ||
|
|
1d4b9ead45 | ||
|
|
c0c4ce1ce6 | ||
|
|
6c2d2ed043 | ||
|
|
58f461b087 | ||
|
|
b97a798ebc | ||
|
|
017cc62e41 | ||
|
|
8ed57b000c | ||
|
|
27bcec7645 | ||
|
|
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
|
||||
|
||||
@@ -4,6 +4,9 @@ FRONTEND_CRITICAL_VITEST := \
|
||||
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
|
||||
src/api/__tests__/client.spec.ts \
|
||||
src/api/__tests__/tokenRefresh.spec.ts \
|
||||
src/api/__tests__/keys.bulkUpdate.spec.ts \
|
||||
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
|
||||
src/views/user/__tests__/KeysView.spec.ts \
|
||||
src/api/__tests__/channelMonitorV2.spec.ts \
|
||||
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
|
||||
src/views/auth/__tests__/WechatCallbackView.spec.ts \
|
||||
|
||||
@@ -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!
|
||||
|
||||
+26
-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>注册体验!
|
||||
@@ -227,6 +219,30 @@ underscores_in_headers on;
|
||||
|
||||
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
|
||||
|
||||
## Codex Fast/Flex 策略说明
|
||||
|
||||
管理员后台的 `系统设置 -> 网关服务 -> OpenAI Fast/Flex 策略` 只负责处理请求体中的 `service_tier`,不会修改 Codex 客户端的模型目录,也不会让 Codex UI 自动出现 Speed 或 `/fast` 选项。
|
||||
|
||||
策略支持以下处理方式:
|
||||
|
||||
- `pass`:保留客户端传入的 `service_tier`;`fast` 会规范为上游使用的 `priority`。
|
||||
- `filter`:移除 `service_tier`,按普通优先级请求。
|
||||
- `block`:拒绝匹配的 Fast/Flex 请求。
|
||||
- `force_priority`:将匹配请求强制设置为 `priority`,会按 Priority/Fast 价格计费。为避免升级后改变既有规则语义,`all` 只匹配显式存在的 tier;如需让省略 `service_tier` 的 OpenAI 请求也强制升级,必须新增 `service_tier=missing + force_priority` 规则。非 OpenAI 平台不会执行缺省 tier 注入。这种方式可以让请求实际使用 Fast,但 Codex UI 仍可能不显示 Fast 状态。
|
||||
|
||||
Codex 的 Fast 入口由客户端模型目录驱动。只有当前模型目录声明了 `additional_speed_tiers: ["fast"]` 和对应的 `service_tiers`,Codex 才会显示 `/fast`。通过 API Key 或自定义模型提供商连接 Sub2API 时,如果模型目录没有这些字段,即使后台配置了 `force_priority`,重启 Codex 后也不会出现 Speed 选项。
|
||||
|
||||
客户端可在 `~/.codex/config.toml` 中直接指定默认请求级别:
|
||||
|
||||
```toml
|
||||
service_tier = "fast"
|
||||
|
||||
[features]
|
||||
fast_mode = true
|
||||
```
|
||||
|
||||
其中 `service_tier = "fast"` 会让请求携带 Fast 设置;`features.fast_mode` 只启用客户端 Fast 功能。模型目录没有声明 Fast 能力时,`/fast` 仍可能不显示。可通过 Sub2API 使用记录确认最终 `service_tier` 是否为 `priority`。
|
||||
|
||||
---
|
||||
|
||||
## 部署方式
|
||||
|
||||
+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.7
|
||||
|
||||
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient)
|
||||
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
||||
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
|
||||
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
leaderLockCache := repository.NewLeaderLockCache(redisClient)
|
||||
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
|
||||
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator, ollamaCloudUsageService)
|
||||
identityCache := repository.NewIdentityCache(redisClient)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
timingWheelService, err := service.ProvideTimingWheelService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -183,7 +185,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
||||
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
|
||||
leaderLockCache := repository.NewLeaderLockCache(redisClient)
|
||||
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
||||
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
|
||||
@@ -203,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)
|
||||
@@ -284,7 +287,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
|
||||
@@ -183,6 +183,7 @@ const (
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
ProviderOpencodeGo Provider = "opencode_go"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -192,7 +193,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -109,6 +109,7 @@ const (
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
ProviderOpencodeGo Provider = "opencode_go"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -118,7 +119,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -623,7 +623,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
|
||||
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
|
||||
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
@@ -776,7 +776,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||
{Name: "extra_headers", Type: field.TypeJSON},
|
||||
|
||||
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
|
||||
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
||||
// probe - LLM 探活(默认,原有行为)
|
||||
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
||||
|
||||
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "minimax":
|
||||
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
|
||||
+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=
|
||||
|
||||
@@ -1274,11 +1274,15 @@ type GatewayOpenAIWSConfig struct {
|
||||
MaxConnsPerAccount int `mapstructure:"max_conns_per_account"`
|
||||
MinIdlePerAccount int `mapstructure:"min_idle_per_account"`
|
||||
MaxIdlePerAccount int `mapstructure:"max_idle_per_account"`
|
||||
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限
|
||||
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限。
|
||||
// 旧版及 mode_router_v2 的 ctx_pool 共用此开关和类型系数;关闭后使用 max_conns_per_account。
|
||||
// mode_router_v2 下并发数 <= 0 的账号仍不可调度。
|
||||
DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"`
|
||||
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor))
|
||||
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor),再受 max_conns_per_account 封顶)。
|
||||
// ctx_pool 接入下每个客户端会话在整个生命周期(含轮次之间)持有一条上游连接,此上限限制的是同时持有连接的会话数,
|
||||
// 在飞请求数另由账号并发槽限制;系数 1.0 会让存活会话数一到并发数就返回 1013 busy,默认 5.0。
|
||||
OAuthMaxConnsFactor float64 `mapstructure:"oauth_max_conns_factor"`
|
||||
// APIKeyMaxConnsFactor: API Key 账号连接池系数(effective=ceil(concurrency*factor))
|
||||
// APIKeyMaxConnsFactor: API Key 账号连接池系数,含义与 OAuthMaxConnsFactor 相同,默认 5.0。
|
||||
APIKeyMaxConnsFactor float64 `mapstructure:"apikey_max_conns_factor"`
|
||||
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
|
||||
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
|
||||
@@ -2048,6 +2052,7 @@ func setDefaults() {
|
||||
"open.bigmodel.cn",
|
||||
"api.minimaxi.com", // MiniMax CN quota + inference
|
||||
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
|
||||
"opencode.ai",
|
||||
"generativelanguage.googleapis.com",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
"*.openai.azure.com",
|
||||
@@ -2375,7 +2380,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
|
||||
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
|
||||
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
|
||||
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
|
||||
viper.SetDefault("gateway.openai_compact_model", "gpt-5.5")
|
||||
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
|
||||
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
|
||||
viper.SetDefault("gateway.openai_ws.enabled", true)
|
||||
@@ -2401,8 +2406,8 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.min_idle_per_account", 4)
|
||||
viper.SetDefault("gateway.openai_ws.max_idle_per_account", 12)
|
||||
viper.SetDefault("gateway.openai_ws.dynamic_max_conns_by_account_concurrency_enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 1.0)
|
||||
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 1.0)
|
||||
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 5.0)
|
||||
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 5.0)
|
||||
viper.SetDefault("gateway.openai_ws.dial_timeout_seconds", 10)
|
||||
viper.SetDefault("gateway.openai_ws.read_timeout_seconds", 900)
|
||||
viper.SetDefault("gateway.openai_ws.write_timeout_seconds", 120)
|
||||
|
||||
@@ -452,11 +452,11 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if !cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled {
|
||||
t.Fatalf("Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = false, want true")
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 1.0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
|
||||
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 5.0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
|
||||
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 5.0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
|
||||
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
|
||||
@@ -574,7 +574,7 @@ func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
|
||||
require.Equal(t, "gpt-5.5", cfg.Gateway.OpenAICompactModel)
|
||||
}
|
||||
|
||||
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,8 @@ import "time"
|
||||
// - "7d" 7 天窗口(Claude/Codex)
|
||||
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
|
||||
// - "7d-fable" Claude 7 天 Fable 独立额度
|
||||
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
|
||||
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
|
||||
// - "monthly" 月窗口(OpenCode Go)
|
||||
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
|
||||
// - "30d" 30 天窗口(Grok 月度)
|
||||
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
|
||||
|
||||
@@ -24,11 +24,14 @@ const (
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
|
||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||
PlatformComposite = "composite"
|
||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
|
||||
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
|
||||
PlatformOpenCodeGo = "opencode_go"
|
||||
PlatformComposite = "composite"
|
||||
)
|
||||
|
||||
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
|
||||
@@ -36,6 +39,8 @@ const (
|
||||
const (
|
||||
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
|
||||
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
|
||||
AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1
|
||||
AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1
|
||||
)
|
||||
|
||||
// API protocol constants 国产供应商的上游 API 协议维度。存储于
|
||||
@@ -146,6 +151,18 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
|
||||
// Gemini 3.7 Flash tiered models
|
||||
"gemini-3.7-flash": "gemini-3.7-flash",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
|
||||
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
"gemini-3.7-flash-tiered": "gemini-3.7-flash-tiered",
|
||||
// Gemini 3.8 Flash tiered models
|
||||
"gemini-3.8-flash": "gemini-3.8-flash",
|
||||
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
||||
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
|
||||
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
|
||||
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
|
||||
"gemini-3-pro-image": "gemini-3.1-flash-image",
|
||||
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
|
||||
|
||||
@@ -89,6 +89,22 @@ func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini37FlashModels(t *testing.T) {
|
||||
for _, model := range []string{"gemini-3.7-flash", "gemini-3.7-flash-high", "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-tiered"} {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != model {
|
||||
t.Fatalf("expected %s to map to itself, got %q", model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
|
||||
for _, model := range []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered"} {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != model {
|
||||
t.Fatalf("expected %s to map to itself, got %q", model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -318,8 +318,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
Protocol: proxy.Protocol,
|
||||
Host: proxy.Host,
|
||||
Port: proxy.Port,
|
||||
Username: proxy.Username,
|
||||
Password: proxy.Password,
|
||||
Username: &proxy.Username,
|
||||
Password: &proxy.Password,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -394,8 +394,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
Port: created.Port,
|
||||
Username: created.Username,
|
||||
Password: created.Password,
|
||||
Username: &created.Username,
|
||||
Password: &created.Password,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1545,6 +1545,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
|
||||
if warning == "missing_project_id_temporary" {
|
||||
response.Success(c, gin.H{
|
||||
"account": h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount),
|
||||
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
|
||||
"warning": "missing_project_id_temporary",
|
||||
})
|
||||
|
||||
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformZhipu: "zhipu",
|
||||
service.PlatformDeepseek: "deepseek",
|
||||
service.PlatformMiniMax: "minimax",
|
||||
service.PlatformOpenCodeGo: "opencode-go",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
|
||||
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
|
||||
type CompositeRouteRequest struct {
|
||||
PublicModel string `json:"public_model" binding:"required"`
|
||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
|
||||
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
|
||||
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
|
||||
// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的
|
||||
// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加,
|
||||
// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。
|
||||
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||
allowed := []string{
|
||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "composite",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
|
||||
}
|
||||
for _, platform := range allowed {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
@@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
|
||||
var req CompositeRouteRequest
|
||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||
require.Equal(t, platform, req.TargetPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_RejectsComposite(t *testing.T) {
|
||||
var req CompositeRouteRequest
|
||||
require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"composite"}`))
|
||||
}
|
||||
|
||||
@@ -27,10 +27,27 @@ func executeAdminIdempotent(
|
||||
payload any,
|
||||
ttl time.Duration,
|
||||
execute func(context.Context) (any, error),
|
||||
) (*service.IdempotencyExecuteResult, error) {
|
||||
return executeAdminIdempotentWithTimeout(c, scope, payload, ttl, 0, execute)
|
||||
}
|
||||
|
||||
func executeAdminIdempotentWithTimeout(
|
||||
c *gin.Context,
|
||||
scope string,
|
||||
payload any,
|
||||
ttl time.Duration,
|
||||
executionTimeout time.Duration,
|
||||
execute func(context.Context) (any, error),
|
||||
) (*service.IdempotencyExecuteResult, error) {
|
||||
coordinator := service.DefaultIdempotencyCoordinator()
|
||||
if coordinator == nil {
|
||||
data, err := execute(c.Request.Context())
|
||||
ctx := c.Request.Context()
|
||||
if executionTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), executionTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
data, err := execute(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -38,14 +55,15 @@ func executeAdminIdempotent(
|
||||
}
|
||||
|
||||
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
|
||||
Scope: scope,
|
||||
ActorScope: adminActorScope(c),
|
||||
Method: c.Request.Method,
|
||||
Route: c.FullPath(),
|
||||
IdempotencyKey: c.GetHeader("Idempotency-Key"),
|
||||
Payload: payload,
|
||||
RequireKey: true,
|
||||
TTL: ttl,
|
||||
Scope: scope,
|
||||
ActorScope: adminActorScope(c),
|
||||
Method: c.Request.Method,
|
||||
Route: c.FullPath(),
|
||||
IdempotencyKey: c.GetHeader("Idempotency-Key"),
|
||||
Payload: payload,
|
||||
RequireKey: true,
|
||||
TTL: ttl,
|
||||
ExecutionTimeout: executionTimeout,
|
||||
}, execute)
|
||||
}
|
||||
|
||||
@@ -64,7 +82,18 @@ func executeAdminIdempotentJSON(
|
||||
ttl time.Duration,
|
||||
execute func(context.Context) (any, error),
|
||||
) {
|
||||
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailClose, execute)
|
||||
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailClose, execute)
|
||||
}
|
||||
|
||||
func executeAdminIdempotentJSONWithTimeout(
|
||||
c *gin.Context,
|
||||
scope string,
|
||||
payload any,
|
||||
ttl time.Duration,
|
||||
executionTimeout time.Duration,
|
||||
execute func(context.Context) (any, error),
|
||||
) {
|
||||
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, executionTimeout, idempotencyStoreUnavailableFailClose, execute)
|
||||
}
|
||||
|
||||
func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
|
||||
@@ -74,7 +103,7 @@ func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
|
||||
ttl time.Duration,
|
||||
execute func(context.Context) (any, error),
|
||||
) {
|
||||
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailOpen, execute)
|
||||
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailOpen, execute)
|
||||
}
|
||||
|
||||
func executeAdminIdempotentJSONWithMode(
|
||||
@@ -82,10 +111,11 @@ func executeAdminIdempotentJSONWithMode(
|
||||
scope string,
|
||||
payload any,
|
||||
ttl time.Duration,
|
||||
executionTimeout time.Duration,
|
||||
mode idempotencyStoreUnavailableMode,
|
||||
execute func(context.Context) (any, error),
|
||||
) {
|
||||
result, err := executeAdminIdempotent(c, scope, payload, ttl, execute)
|
||||
result, err := executeAdminIdempotentWithTimeout(c, scope, payload, ttl, executionTimeout, execute)
|
||||
if err != nil {
|
||||
if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) {
|
||||
strategy := "fail_close"
|
||||
|
||||
@@ -219,7 +219,8 @@ func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
|
||||
// GetDashboardOpenAITokenStats returns token efficiency stats for all platforms, grouped by model.
|
||||
// The endpoint name is retained for compatibility; platform and group_id optionally narrow the scope.
|
||||
// GET /api/v1/admin/ops/dashboard/openai-token-stats
|
||||
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProxyHandlerUpdateCredentialPresence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
check := func(name, body string, username, password *string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := &proxyPartialUpdateService{}
|
||||
router := gin.New()
|
||||
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NotNil(t, svc.input)
|
||||
require.Equal(t, username, svc.input.Username)
|
||||
require.Equal(t, password, svc.input.Password)
|
||||
})
|
||||
}
|
||||
empty, username, password := "", "user", "pass"
|
||||
check("omitted", `{"status":"inactive"}`, nil, nil)
|
||||
check("null preserves", `{"username":null,"password":null}`, nil, nil)
|
||||
check("clear both", `{"username":"","password":""}`, &empty, &empty)
|
||||
check("clear username only", `{"username":""}`, &empty, nil)
|
||||
check("clear password only", `{"password":""}`, nil, &empty)
|
||||
check("trim values", `{"username":" user ","password":" pass "}`, &username, &password)
|
||||
check("whitespace clears", `{"username":" ","password":" "}`, &empty, &empty)
|
||||
}
|
||||
@@ -182,8 +182,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
Protocol: existing.Protocol,
|
||||
Host: existing.Host,
|
||||
Port: existing.Port,
|
||||
Username: existing.Username,
|
||||
Password: existing.Password,
|
||||
Username: &existing.Username,
|
||||
Password: &existing.Password,
|
||||
}
|
||||
if _, err := h.adminService.UpdateProxy(ctx, existing.ID, updateInput); err != nil {
|
||||
result.Errors = append(result.Errors, DataImportError{
|
||||
@@ -266,8 +266,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
Port: created.Port,
|
||||
Username: created.Username,
|
||||
Password: created.Password,
|
||||
Username: &created.Username,
|
||||
Password: &created.Password,
|
||||
}); err != nil {
|
||||
result.Errors = append(result.Errors, DataImportError{
|
||||
Kind: "proxy",
|
||||
|
||||
@@ -45,8 +45,8 @@ type UpdateProxyRequest struct {
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
ExpiresAt dto.NullableInt64Field `json:"expires_at"`
|
||||
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
|
||||
@@ -187,13 +187,19 @@ func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
t := time.Unix(*req.ExpiresAt.Value, 0).UTC()
|
||||
expiresAt = &t
|
||||
}
|
||||
if req.Username != nil {
|
||||
*req.Username = strings.TrimSpace(*req.Username)
|
||||
}
|
||||
if req.Password != nil {
|
||||
*req.Password = strings.TrimSpace(*req.Password)
|
||||
}
|
||||
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Protocol: strings.TrimSpace(req.Protocol),
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Port: req.Port,
|
||||
Username: strings.TrimSpace(req.Username),
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil,
|
||||
|
||||
@@ -383,6 +383,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: settings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -579,6 +579,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||
changed = append(changed, "available_channels_enabled")
|
||||
}
|
||||
if before.SubscriptionEnabled != after.SubscriptionEnabled {
|
||||
changed = append(changed, "subscription_enabled")
|
||||
}
|
||||
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
|
||||
changed = append(changed, "model_plaza_enabled")
|
||||
}
|
||||
|
||||
@@ -186,3 +186,20 @@ func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *t
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "positive integer")
|
||||
}
|
||||
|
||||
// subscription_enabled is an opt-out switch: an explicit false is written as-is,
|
||||
// and a later payload that omits the field keeps the stored value.
|
||||
func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeySubscriptionEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled])
|
||||
|
||||
rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled],
|
||||
"a payload without subscription_enabled must not flip the stored value back to true")
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ type UpdateSettingsRequest struct {
|
||||
// Available Channels feature switch (user-facing)
|
||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch (user-facing subscription surface; see SettingKeySubscriptionEnabled)
|
||||
SubscriptionEnabled *bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature switches + description
|
||||
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
@@ -1937,6 +1940,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.AvailableChannelsEnabled
|
||||
}(),
|
||||
SubscriptionEnabled: func() bool {
|
||||
if req.SubscriptionEnabled != nil {
|
||||
return *req.SubscriptionEnabled
|
||||
}
|
||||
return previousSettings.SubscriptionEnabled
|
||||
}(),
|
||||
ModelPlazaEnabled: func() bool {
|
||||
if req.ModelPlazaEnabled != nil {
|
||||
return *req.ModelPlazaEnabled
|
||||
@@ -2380,6 +2389,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: updatedSettings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type bulkActionHandlerSubscriptionRepo struct {
|
||||
service.UserSubscriptionRepository
|
||||
sub *service.UserSubscription
|
||||
extendCalls int
|
||||
}
|
||||
|
||||
type cancellationAwareBulkActionRepo struct {
|
||||
*bulkActionHandlerSubscriptionRepo
|
||||
cancelRequest context.CancelFunc
|
||||
}
|
||||
|
||||
func (r *cancellationAwareBulkActionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.bulkActionHandlerSubscriptionRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *cancellationAwareBulkActionRepo) ExtendExpiry(ctx context.Context, id int64, expiry time.Time) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.bulkActionHandlerSubscriptionRepo.ExtendExpiry(ctx, id, expiry)
|
||||
r.cancelRequest()
|
||||
return err
|
||||
}
|
||||
|
||||
type cancellationAwareAdminIdempotencyRepo struct {
|
||||
*memoryIdempotencyRepoStub
|
||||
saves int
|
||||
}
|
||||
|
||||
func (r *cancellationAwareAdminIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, status int, body string, expiresAt time.Time) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.saves++
|
||||
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, status, body, expiresAt)
|
||||
}
|
||||
|
||||
func (r *bulkActionHandlerSubscriptionRepo) GetByID(_ context.Context, id int64) (*service.UserSubscription, error) {
|
||||
if r.sub == nil || r.sub.ID != id {
|
||||
return nil, service.ErrSubscriptionNotFound
|
||||
}
|
||||
copy := *r.sub
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *bulkActionHandlerSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *bulkActionHandlerSubscriptionRepo) ExtendExpiry(_ context.Context, _ int64, expiry time.Time) error {
|
||||
r.extendCalls++
|
||||
r.sub.ExpiresAt = expiry
|
||||
return nil
|
||||
}
|
||||
|
||||
func bulkActionHandlerRequest(router *gin.Engine, path, body, key string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestSubscriptionBulkAction_ReplaysPartialResultWithoutRepeatingExtension(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := service.DefaultIdempotencyConfig()
|
||||
cfg.ObserveOnly = false
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
|
||||
expiresAt := time.Now().AddDate(0, 0, 30)
|
||||
repo := &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}}
|
||||
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
|
||||
t.Cleanup(svc.Stop)
|
||||
h := NewSubscriptionHandler(svc)
|
||||
router := gin.New()
|
||||
path := "/api/v1/admin/subscriptions/bulk-action"
|
||||
router.POST(path, h.BulkAction)
|
||||
body := `{"subscription_ids":[1,404,1],"action":"extend","days":7}`
|
||||
|
||||
missingKey := bulkActionHandlerRequest(router, path, body, "")
|
||||
require.Equal(t, http.StatusBadRequest, missingKey.Code)
|
||||
require.Zero(t, repo.extendCalls)
|
||||
|
||||
first := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
var result struct {
|
||||
Data service.BulkSubscriptionActionResult `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &result))
|
||||
require.Equal(t, 1, result.Data.SuccessCount)
|
||||
require.Equal(t, 1, result.Data.FailedCount)
|
||||
require.Len(t, result.Data.Results, 2)
|
||||
|
||||
replayed := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
|
||||
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
|
||||
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
|
||||
require.JSONEq(t, first.Body.String(), replayed.Body.String())
|
||||
require.Equal(t, 1, repo.extendCalls)
|
||||
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
|
||||
|
||||
conflict := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1,404,1],"action":"extend","days":14}`, "bulk-extend-once")
|
||||
require.Equal(t, http.StatusConflict, conflict.Code)
|
||||
require.Equal(t, 1, repo.extendCalls)
|
||||
}
|
||||
|
||||
func TestSubscriptionBulkAction_ClientCancellationStillPersistsReplay(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
idempotencyRepo := &cancellationAwareAdminIdempotencyRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub()}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(idempotencyRepo, service.DefaultIdempotencyConfig()))
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
|
||||
requestCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
expiresAt := time.Now().AddDate(0, 0, 30)
|
||||
repo := &cancellationAwareBulkActionRepo{
|
||||
bulkActionHandlerSubscriptionRepo: &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}},
|
||||
cancelRequest: cancel,
|
||||
}
|
||||
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
|
||||
t.Cleanup(svc.Stop)
|
||||
router := gin.New()
|
||||
path := "/api/v1/admin/subscriptions/bulk-action"
|
||||
router.POST(path, NewSubscriptionHandler(svc).BulkAction)
|
||||
body := `{"subscription_ids":[1],"action":"extend","days":7}`
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)).WithContext(requestCtx)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Idempotency-Key", "canceled-request")
|
||||
first := httptest.NewRecorder()
|
||||
router.ServeHTTP(first, request)
|
||||
require.ErrorIs(t, requestCtx.Err(), context.Canceled)
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
require.Equal(t, 1, idempotencyRepo.saves)
|
||||
|
||||
replayed := bulkActionHandlerRequest(router, path, body, "canceled-request")
|
||||
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
|
||||
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
|
||||
require.JSONEq(t, first.Body.String(), replayed.Body.String())
|
||||
require.Equal(t, 1, repo.extendCalls)
|
||||
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
|
||||
}
|
||||
|
||||
func TestSubscriptionBulkAction_ValidatesBeforeIdempotencyAndExecution(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(storeUnavailableRepoStub{}, service.DefaultIdempotencyConfig()))
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
|
||||
router := gin.New()
|
||||
path := "/api/v1/admin/subscriptions/bulk-action"
|
||||
router.POST(path, NewSubscriptionHandler(nil).BulkAction)
|
||||
tooManyIDs := make([]int64, 101)
|
||||
for i := range tooManyIDs {
|
||||
tooManyIDs[i] = 1
|
||||
}
|
||||
tooManyBody, err := json.Marshal(service.BulkSubscriptionActionInput{SubscriptionIDs: tooManyIDs, Action: "revoke"})
|
||||
require.NoError(t, err)
|
||||
for _, body := range []string{
|
||||
`{"subscription_ids":[1],"action":"delete"}`,
|
||||
`{"subscription_ids":[1,0],"action":"revoke"}`,
|
||||
`{"subscription_ids":[],"action":"restore"}`,
|
||||
`{"subscription_ids":[1],"action":"extend","days":0}`,
|
||||
`{"subscription_ids":[1],"action":"extend","days":36501}`,
|
||||
`{"subscription_ids":[1],"action":"reset_quota"}`,
|
||||
`{"subscription_ids":[1],"action":"reset_quota","daily":"yes"}`,
|
||||
`{"subscription_ids":[1],"action":"revoke"`,
|
||||
string(tooManyBody),
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
response := bulkActionHandlerRequest(router, path, body, "bulk-invalid")
|
||||
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// A valid request must still fail closed when replay protection is unavailable.
|
||||
response := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1],"action":"revoke"}`, "bulk-valid")
|
||||
require.Equal(t, http.StatusServiceUnavailable, response.Code)
|
||||
}
|
||||
|
||||
func TestSubscriptionBulkAssign_RejectsInvalidUserIDsBeforeExecution(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
path := "/api/v1/admin/subscriptions/bulk-assign"
|
||||
router.POST(path, NewSubscriptionHandler(nil).BulkAssign)
|
||||
for _, ids := range [][]int64{{}, {1, 0}, {1, -1}, make([]int64, 101)} {
|
||||
t.Run(fmt.Sprint(len(ids), ids), func(t *testing.T) {
|
||||
body, err := json.Marshal(BulkAssignSubscriptionRequest{UserIDs: ids, GroupID: 1, ValidityDays: 30})
|
||||
require.NoError(t, err)
|
||||
response := bulkActionHandlerRequest(router, path, string(body), "")
|
||||
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
@@ -48,7 +49,7 @@ type AssignSubscriptionRequest struct {
|
||||
|
||||
// BulkAssignSubscriptionRequest represents bulk assign subscription request
|
||||
type BulkAssignSubscriptionRequest struct {
|
||||
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
|
||||
UserIDs []int64 `json:"user_ids" binding:"required,min=1,max=100,dive,gt=0"`
|
||||
GroupID int64 `json:"group_id" binding:"required"`
|
||||
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
|
||||
Notes string `json:"notes"`
|
||||
@@ -186,6 +187,23 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
|
||||
response.Success(c, dto.BulkAssignResultFromService(result))
|
||||
}
|
||||
|
||||
// BulkAction applies one operation to selected subscriptions, returning each outcome.
|
||||
// POST /api/v1/admin/subscriptions/bulk-action
|
||||
func (h *SubscriptionHandler) BulkAction(c *gin.Context) {
|
||||
var req service.BulkSubscriptionActionInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
executeAdminIdempotentJSONWithTimeout(c, "admin.subscriptions.bulk-action", req, service.DefaultWriteIdempotencyTTL(), 2*time.Minute, func(ctx context.Context) (any, error) {
|
||||
return h.subscriptionService.BulkSubscriptionAction(ctx, &req)
|
||||
})
|
||||
}
|
||||
|
||||
// Extend handles adjusting a subscription (extend or shorten)
|
||||
// POST /api/v1/admin/subscriptions/:id/extend
|
||||
func (h *SubscriptionHandler) Extend(c *gin.Context) {
|
||||
|
||||
@@ -790,15 +790,21 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 三档全空的输入不落库:user_platform_quotas 只保存至少配置了一档限额的记录,
|
||||
// 不存在的行等价于不限额。未进入 records 的平台由 UpsertForUser 软删,审计里记为 removed。
|
||||
records := make([]service.UserPlatformQuotaRecord, 0, len(req.Quotas))
|
||||
for _, q := range req.Quotas {
|
||||
records = append(records, service.UserPlatformQuotaRecord{
|
||||
rec := service.UserPlatformQuotaRecord{
|
||||
UserID: userID,
|
||||
Platform: q.Platform,
|
||||
DailyLimitUSD: q.DailyLimitUSD,
|
||||
WeeklyLimitUSD: q.WeeklyLimitUSD,
|
||||
MonthlyLimitUSD: q.MonthlyLimitUSD,
|
||||
})
|
||||
}
|
||||
if !rec.HasAnyLimit() {
|
||||
continue
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
@@ -860,6 +866,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
slog.Info("admin.quota_updated",
|
||||
"actor_admin_id", getAdminIDFromContext(c),
|
||||
"target_user_id", userID,
|
||||
"submitted_count", len(req.Quotas),
|
||||
"platform_count", len(records),
|
||||
"before_snapshot_available", beforeErr == nil,
|
||||
"changes", changes)
|
||||
|
||||
@@ -112,16 +112,61 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
if len(repo.upsertCalls) != 1 {
|
||||
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
|
||||
}
|
||||
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
|
||||
// upsert 记录数 = 请求体中至少配置了一档限额的平台数;三档全空的平台不落库,
|
||||
// 由 UpsertForUser 的"不在列表里就软删"分支处理。
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
|
||||
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
|
||||
}
|
||||
for _, r := range repo.upsertCalls[0].records {
|
||||
if r.Platform != "anthropic" && r.Platform != "openai" {
|
||||
t.Errorf("platform %q has no configured limit and must not be upserted", r.Platform)
|
||||
}
|
||||
}
|
||||
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
|
||||
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
|
||||
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows 锁定:全部平台三档全空等价于清空,
|
||||
// UpsertForUser 收到空列表(软删该用户所有活跃行),且 0 = 显式禁用仍算已配置。
|
||||
func TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows(t *testing.T) {
|
||||
repo := &upsertCapturingQuotaRepo{}
|
||||
cache := &billingCacheStub{}
|
||||
h := buildTestHandler(repo, cache)
|
||||
|
||||
body := `{"quotas":[
|
||||
{"platform":"anthropic","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
|
||||
{"platform":"openai"}
|
||||
]}`
|
||||
c, w := putReq(t, body)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.upsertCalls) != 1 {
|
||||
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
|
||||
}
|
||||
if len(repo.upsertCalls[0].records) != 0 {
|
||||
t.Errorf("all-unlimited input must upsert zero records, got %+v", repo.upsertCalls[0].records)
|
||||
}
|
||||
|
||||
repo = &upsertCapturingQuotaRepo{}
|
||||
h = buildTestHandler(repo, &billingCacheStub{})
|
||||
c, w = putReq(t, `{"quotas":[{"platform":"gemini","daily_limit_usd":0}]}`)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.upsertCalls) != 1 || len(repo.upsertCalls[0].records) != 1 {
|
||||
t.Fatalf("zero limit is a configured limit and must be upserted: %+v", repo.upsertCalls)
|
||||
}
|
||||
if r := repo.upsertCalls[0].records[0]; r.Platform != "gemini" || r.DailyLimitUSD == nil || *r.DailyLimitUSD != 0 {
|
||||
t.Errorf("unexpected record: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPlatformQuotas_RejectsDuplicatePlatform(t *testing.T) {
|
||||
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
|
||||
body := `{"quotas":[
|
||||
|
||||
@@ -318,6 +318,10 @@ type SystemSettings struct {
|
||||
// Available Channels feature switch (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch: gates the whole user-facing subscription surface
|
||||
// (sidebar entries, purchase-page subscription tab, header badge, /subscriptions route).
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
@@ -404,7 +408,11 @@ type PublicSettings struct {
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
// PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the
|
||||
// user shell can derive the site billing mode (recharge & subscription / recharge only /
|
||||
// subscription only) before any authenticated checkout call.
|
||||
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
|
||||
Version string `json:"version"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
|
||||
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
@@ -423,6 +431,8 @@ type PublicSettings struct {
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
@@ -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):
|
||||
@@ -315,7 +318,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
||||
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
|
||||
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
|
||||
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
|
||||
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
|
||||
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) {
|
||||
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
|
||||
@@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
||||
result: &service.OpenAIForwardResult{},
|
||||
want: EndpointResponses,
|
||||
},
|
||||
{
|
||||
name: "opencode go conversion result reports responses",
|
||||
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
|
||||
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
|
||||
want: EndpointResponses,
|
||||
},
|
||||
{
|
||||
name: "opencode go empty result without runtime stays inbound",
|
||||
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
|
||||
result: &service.OpenAIForwardResult{},
|
||||
want: EndpointChatCompletions,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1269,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]string, 0)
|
||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
|
||||
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
if len(platformModels) == 0 {
|
||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
|
||||
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
|
||||
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
|
||||
platformModels = defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
}
|
||||
@@ -1422,7 +1422,7 @@ func modelListingSource(platform string, availableModels, fallbackModels []strin
|
||||
func defaultCodexModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformDeepseek:
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}
|
||||
case service.PlatformMiniMax:
|
||||
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
|
||||
default:
|
||||
@@ -1451,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
return claude.DefaultModelIDs()
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformOpenCodeGo:
|
||||
return service.DefaultOpenCodeGoModelIDs()
|
||||
case service.PlatformComposite:
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
|
||||
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -914,7 +914,7 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
|
||||
}
|
||||
|
||||
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
@@ -198,30 +211,57 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
}
|
||||
routingStart := time.Now()
|
||||
requiredCapability := grokMediaRequiredCapability(endpoint)
|
||||
var accountReleaseFunc func()
|
||||
releaseAccount := func() {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
accountReleaseFunc = nil
|
||||
}
|
||||
}
|
||||
defer releaseAccount()
|
||||
|
||||
for {
|
||||
releaseAccount()
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
requestCtx,
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
routingModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
requiredCapability,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
service.PlatformGrok,
|
||||
)
|
||||
var selection *service.AccountSelectionResult
|
||||
var scheduleDecision service.OpenAIAccountScheduleDecision
|
||||
if boundLookupAccountID > 0 {
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount(
|
||||
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform,
|
||||
)
|
||||
} else {
|
||||
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
requestCtx,
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
routingModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
requiredCapability,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
platform,
|
||||
)
|
||||
}
|
||||
// Own an eagerly acquired slot before any rejection or eligibility probe.
|
||||
// Forwarding takes over the same once-only release after admission.
|
||||
if selection != nil && selection.Acquired {
|
||||
selection.ReleaseFunc = wrapReleaseOnDone(requestCtx, selection.ReleaseFunc)
|
||||
accountReleaseFunc = selection.ReleaseFunc
|
||||
}
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("grok_media.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if boundLookupAccountID > 0 && errors.Is(err, service.ErrNoAvailableAccounts) {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Video request not found")
|
||||
return
|
||||
}
|
||||
reqLog.Warn("grok_media.account_select_failed",
|
||||
zap.Error(err),
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
@@ -229,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)
|
||||
}
|
||||
@@ -250,16 +290,19 @@ 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)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
if boundLookupAccountID > 0 && selection.Account.ID != boundLookupAccountID {
|
||||
reqLog.Warn("grok_media.video_lookup_bound_account_unavailable",
|
||||
zap.Int64("bound_account_id", boundLookupAccountID),
|
||||
@@ -279,9 +322,10 @@ 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()
|
||||
mediaEligibilityRejected = true
|
||||
failedAccountIDs[account.ID] = struct{}{}
|
||||
reqLog.Warn("grok_media.account_eligibility_rejected",
|
||||
@@ -298,10 +342,19 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
admissionSessionHash := sessionHash
|
||||
if boundLookupAccountID > 0 {
|
||||
// Waiting must not replace the video ownership TTL with text sticky TTL.
|
||||
admissionSessionHash = ""
|
||||
}
|
||||
var slotResult openAISlotAcquireResult
|
||||
accountReleaseFunc, slotResult = h.acquireResponsesAccountSlot(c, apiKey.GroupID, admissionSessionHash, selection, false, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 媒体路径已显式豁免利润门(suppress 标记),此分支仅防御性兜底,
|
||||
// 同样受否决上限约束。
|
||||
@@ -319,11 +372,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
forwardStart := time.Now()
|
||||
writerSizeBeforeForward := c.Writer.Size()
|
||||
result, err := func() (*service.OpenAIForwardResult, error) {
|
||||
defer func() {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
}()
|
||||
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)
|
||||
}()
|
||||
|
||||
@@ -454,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)
|
||||
@@ -485,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
|
||||
}
|
||||
@@ -503,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
|
||||
@@ -676,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
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/testutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type grokMediaSlotsCache struct {
|
||||
testutil.StubConcurrencyCache
|
||||
mu sync.Mutex
|
||||
accounts map[string]int64
|
||||
users map[string]int64
|
||||
acquired, released, userAcquired, userReleased int
|
||||
waiting, denied int
|
||||
full bool
|
||||
queueFull bool
|
||||
onWait func()
|
||||
duplicateRelease bool
|
||||
peak int
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) AcquireAccountSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.full || s.denied > 0 {
|
||||
if s.denied > 0 {
|
||||
s.denied--
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
s.accounts[request] = id
|
||||
s.acquired++
|
||||
if len(s.accounts) > s.peak {
|
||||
s.peak = len(s.accounts)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) ReleaseAccountSlot(_ context.Context, _ int64, request string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.accounts[request]; !ok {
|
||||
s.duplicateRelease = true
|
||||
}
|
||||
delete(s.accounts, request)
|
||||
s.released++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) AcquireUserSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users[request] = id
|
||||
s.userAcquired++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) ReleaseUserSlot(_ context.Context, _ int64, request string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.users[request]; !ok {
|
||||
s.duplicateRelease = true
|
||||
}
|
||||
delete(s.users, request)
|
||||
s.userReleased++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) IncrementAccountWaitCount(context.Context, int64, int) (bool, error) {
|
||||
s.mu.Lock()
|
||||
if s.queueFull {
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
s.waiting++
|
||||
onWait := s.onWait
|
||||
s.mu.Unlock()
|
||||
if onWait != nil {
|
||||
onWait()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) DecrementAccountWaitCount(context.Context, int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.waiting--
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
|
||||
t.Helper()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
require.Empty(t, s.accounts)
|
||||
require.Empty(t, s.users)
|
||||
require.Equal(t, s.acquired, s.released)
|
||||
require.Equal(t, s.userAcquired, s.userReleased)
|
||||
require.False(t, s.duplicateRelease)
|
||||
require.Zero(t, s.waiting)
|
||||
}
|
||||
|
||||
type grokMediaSlotBindings struct {
|
||||
testutil.StubGatewayCache
|
||||
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) {
|
||||
if groupID != 24 || key != s.key {
|
||||
return 0, service.ErrStickySessionNotFound
|
||||
}
|
||||
return s.owner, nil
|
||||
}
|
||||
func (s *grokMediaSlotBindings) SetSessionAccountID(_ context.Context, _ int64, key string, owner int64, _ time.Duration) error {
|
||||
s.key, s.owner = key, owner
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
func (s *grokMediaSlotBindings) RefreshSessionTTL(context.Context, int64, string, time.Duration) error {
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
func (s *grokMediaSlotBindings) DeleteSessionAccountID(context.Context, int64, string) error {
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *grokMediaSlotBindings) ClaimGrokVideoBilled(_ context.Context, key string, _ time.Duration) (bool, error) {
|
||||
if s.billed == nil {
|
||||
s.billed = make(map[string]bool)
|
||||
}
|
||||
if s.billed[key] {
|
||||
return false, nil
|
||||
}
|
||||
s.billed[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type grokMediaSlotRepo struct {
|
||||
openAIImagesFailoverAccountRepo
|
||||
mismatch bool
|
||||
}
|
||||
|
||||
func (r grokMediaSlotRepo) GetByID(ctx context.Context, id int64) (*service.Account, error) {
|
||||
if r.mismatch {
|
||||
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, 2)
|
||||
}
|
||||
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
type grokMediaSlotUpstream struct {
|
||||
service.HTTPUpstream
|
||||
call func(*http.Request, int64) (*http.Response, error)
|
||||
calls int
|
||||
}
|
||||
|
||||
func (u *grokMediaSlotUpstream) Do(req *http.Request, _ string, id int64, _ int) (*http.Response, error) {
|
||||
u.calls++
|
||||
return u.call(req, id)
|
||||
}
|
||||
|
||||
type grokMediaSlotProber func(context.Context, int64) (bool, string, error)
|
||||
|
||||
func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64) (bool, string, error) {
|
||||
return p(ctx, id)
|
||||
}
|
||||
|
||||
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)
|
||||
for i := range accounts {
|
||||
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"
|
||||
accounts[i].Credentials["expires_at"] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
slots := &grokMediaSlotsCache{accounts: map[string]int64{}, users: map[string]int64{}}
|
||||
concurrency := service.NewConcurrencyService(slots)
|
||||
bindings := &grokMediaSlotBindings{owner: 1}
|
||||
upstream := &grokMediaSlotUpstream{call: func(*http.Request, int64) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"request_id":"task","status":"pending"}`))}, nil
|
||||
}}
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 20 * time.Millisecond
|
||||
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 3
|
||||
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
|
||||
repo := grokMediaSlotRepo{openAIImagesFailoverAccountRepo: openAIImagesFailoverAccountRepo{accounts: accounts}, mismatch: mismatch}
|
||||
provider := service.NewGrokTokenProvider(repo, nil)
|
||||
if oauth {
|
||||
_, err := provider.GetAccessToken(context.Background(), &accounts[1])
|
||||
require.NoError(t, err)
|
||||
}
|
||||
gateway := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, bindings, cfg, nil, concurrency, nil, nil, nil, upstream, nil, nil, provider, nil, nil, nil, nil, nil)
|
||||
groupID := int64(24)
|
||||
require.NoError(t, gateway.BindGrokMediaVideoRequestAccount(context.Background(), &groupID, "task", 10, 20, 1))
|
||||
bindings.writes = 0
|
||||
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billing.Stop)
|
||||
handler := NewOpenAIGatewayHandler(gateway, concurrency, billing, service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg), nil, nil, nil, nil, cfg)
|
||||
return handler, slots, bindings, upstream
|
||||
}
|
||||
|
||||
func grokMediaSlotContext(ctx context.Context, generation bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
groupID := int64(24)
|
||||
method, path, body := http.MethodGet, "/v1/videos/task", ""
|
||||
if generation {
|
||||
method, path, body = http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video","prompt":"test","duration":6}`
|
||||
}
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body)).WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if !generation {
|
||||
req.Header.Set("session-id", "unrelated-client-session")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "request_id", Value: "task"}}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 20, UserID: 10, GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, AllowImageGeneration: true}, User: &service.User{ID: 10}})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 10, Concurrency: 5})
|
||||
return c, w
|
||||
}
|
||||
|
||||
func TestGrokMediaLookupSlotLifecycle(t *testing.T) {
|
||||
for _, scenario := range []string{"normal", "mismatch acquired", "mismatch wait", "full", "queue full", "cancel while waiting", "wait then acquired", "upstream error", "cancel", "panic"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, strings.HasPrefix(scenario, "mismatch"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if scenario == "full" || scenario == "mismatch wait" || scenario == "queue full" || scenario == "cancel while waiting" {
|
||||
slots.full = true
|
||||
}
|
||||
if scenario == "queue full" {
|
||||
slots.queueFull = true
|
||||
}
|
||||
if scenario == "cancel while waiting" {
|
||||
slots.onWait = cancel
|
||||
}
|
||||
if scenario == "wait then acquired" {
|
||||
slots.denied = 1
|
||||
}
|
||||
original := upstream.call
|
||||
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
|
||||
require.Equal(t, int64(1), id)
|
||||
switch scenario {
|
||||
case "upstream error":
|
||||
return nil, errors.New("upstream unavailable")
|
||||
case "cancel":
|
||||
cancel()
|
||||
return nil, context.Canceled
|
||||
case "panic":
|
||||
panic("test upstream panic")
|
||||
}
|
||||
return original(req, id)
|
||||
}
|
||||
for range 20 {
|
||||
c, w := grokMediaSlotContext(ctx, false)
|
||||
h.GrokVideoStatus(c)
|
||||
slots.assertReleased(t)
|
||||
if strings.HasPrefix(scenario, "mismatch") {
|
||||
require.Equal(t, 404, w.Code)
|
||||
}
|
||||
if scenario == "normal" || scenario == "wait then acquired" {
|
||||
require.Equal(t, 200, w.Code)
|
||||
}
|
||||
if scenario == "full" || scenario == "queue full" {
|
||||
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
}
|
||||
}
|
||||
require.Zero(t, bindings.writes, "lookups must preserve owner and TTL")
|
||||
if scenario == "mismatch acquired" {
|
||||
require.Equal(t, 20, slots.released)
|
||||
}
|
||||
if slots.full {
|
||||
require.Zero(t, slots.released)
|
||||
require.Zero(t, upstream.calls)
|
||||
}
|
||||
if scenario == "full" || strings.HasPrefix(scenario, "mismatch") {
|
||||
require.Zero(t, upstream.calls)
|
||||
}
|
||||
switch scenario {
|
||||
case "normal", "wait then acquired", "upstream error", "panic":
|
||||
require.Equal(t, 20, upstream.calls)
|
||||
require.Equal(t, 20, slots.released)
|
||||
case "cancel":
|
||||
require.Equal(t, 1, upstream.calls)
|
||||
require.Equal(t, 1, slots.released)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityReleasesBeforeSwitch(t *testing.T) {
|
||||
for _, scenario := range []string{"switch", "exhausted", "cancel during probe"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
h, slots, _, upstream := newGrokMediaSlotHandler(t, true, false)
|
||||
h.maxAccountSwitches = 1
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
probes := 0
|
||||
h.grokMediaEligibilityProber = grokMediaSlotProber(func(context.Context, int64) (bool, string, error) {
|
||||
probes++
|
||||
if scenario == "cancel during probe" {
|
||||
cancel()
|
||||
require.Eventually(t, func() bool { slots.mu.Lock(); defer slots.mu.Unlock(); return len(slots.accounts) == 0 }, time.Second, time.Millisecond)
|
||||
}
|
||||
if scenario == "switch" && probes == 2 {
|
||||
return true, "eligible", nil
|
||||
}
|
||||
return false, "billing_unobserved", errors.New("probe failed")
|
||||
})
|
||||
c, w := grokMediaSlotContext(ctx, true)
|
||||
h.GrokVideoGeneration(c)
|
||||
slots.assertReleased(t)
|
||||
require.Equal(t, 1, slots.peak, "rejected attempts must release before the next selection")
|
||||
if scenario == "switch" {
|
||||
events, _ := c.Get(service.OpsUpstreamErrorsKey)
|
||||
detail, err := json.Marshal(events)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, w.Code, "body=%s probes=%d upstream=%d events=%s", w.Body.String(), probes, upstream.calls, detail)
|
||||
require.Equal(t, 1, upstream.calls)
|
||||
}
|
||||
if scenario == "exhausted" {
|
||||
require.Equal(t, 503, w.Code)
|
||||
require.Equal(t, 2, slots.released)
|
||||
require.Zero(t, upstream.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMediaVideoLookupOwnerIsolation(t *testing.T) {
|
||||
for _, other := range []string{"user", "api key", "group", "task"} {
|
||||
t.Run(other, func(t *testing.T) {
|
||||
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false)
|
||||
c, w := grokMediaSlotContext(context.Background(), false)
|
||||
key, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
require.True(t, ok)
|
||||
switch other {
|
||||
case "user":
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11, Concurrency: 5})
|
||||
case "api key":
|
||||
key.ID = 21
|
||||
case "group":
|
||||
groupID := int64(25)
|
||||
key.GroupID = &groupID
|
||||
case "task":
|
||||
c.Params = gin.Params{{Key: "request_id", Value: "other-task"}}
|
||||
}
|
||||
h.GrokVideoStatus(c)
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
slots.assertReleased(t)
|
||||
require.Zero(t, slots.acquired)
|
||||
require.Zero(t, upstream.calls)
|
||||
require.Zero(t, bindings.writes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMediaVideoCompletionStillClaimsBillingOnce(t *testing.T) {
|
||||
h, _, bindings, _ := newGrokMediaSlotHandler(t, false, false)
|
||||
c, _ := grokMediaSlotContext(context.Background(), false)
|
||||
key, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
require.True(t, ok)
|
||||
subject := middleware2.AuthSubject{UserID: 10, Concurrency: 5}
|
||||
result := &service.OpenAIForwardResult{ResponseID: "task", Model: "grok-imagine-video",
|
||||
VideoCount: 1, VideoDurationSeconds: 6}
|
||||
for i := range 20 {
|
||||
bill := prepareGrokVideoCompletionBilling(c.Request.Context(), h, zap.NewNop(), key, subject, "task", result)
|
||||
if i == 0 {
|
||||
require.NotNil(t, bill)
|
||||
require.Equal(t, service.StableGrokVideoBillingRequestID("task"), bill.RequestID)
|
||||
} else {
|
||||
require.Nil(t, bill)
|
||||
}
|
||||
}
|
||||
require.Len(t, bindings.billed, 1)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
|
||||
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} {
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformGrok} {
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
||||
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
||||
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
|
||||
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
|
||||
}
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
|
||||
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return ""
|
||||
}
|
||||
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
|
||||
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
||||
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
|
||||
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
||||
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
||||
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
||||
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
|
||||
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -305,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
||||
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
|
||||
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
|
||||
// 若不豁免,CN 分组将永远 403。
|
||||
if service.IsCNProvider(apiKey.Group.Platform) {
|
||||
if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) {
|
||||
return true
|
||||
}
|
||||
// composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免;
|
||||
// composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免;
|
||||
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
|
||||
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
||||
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
||||
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
|
||||
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,8 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
||||
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
|
||||
return compositeTargetPlatformAllowed(c, apiKey, model,
|
||||
service.PlatformOpenAI, service.PlatformGrok,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax)
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
|
||||
service.PlatformMiniMax, service.PlatformOpenCodeGo)
|
||||
}
|
||||
|
||||
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
|
||||
@@ -3016,7 +3017,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
|
||||
// WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。
|
||||
requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage)
|
||||
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, wsFirstMessage); armed {
|
||||
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemptionWithClient(ctx, c, account, wsFirstMessage, wsConn); armed {
|
||||
ctx = preemptCtx
|
||||
defer cleanupPreempt()
|
||||
}
|
||||
@@ -3028,6 +3029,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if service.IsOpenAIWSSessionPreemptedError(err) {
|
||||
// 关闭帧已由抢占登记在取消前发给本连接,这里只记录并释放。
|
||||
reqLog.Info("openai.websocket_ingress_preempted", zap.Int64("account_id", account.ID))
|
||||
return
|
||||
}
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
|
||||
@@ -157,7 +157,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
|
||||
)
|
||||
handler.maxAccountSwitches = 10
|
||||
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
|
||||
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
|
||||
core, observedLogs := observer.New(zap.DebugLevel)
|
||||
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
|
||||
|
||||
@@ -1124,8 +1124,12 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
// A marked in-band error is a visible request failure even though its
|
||||
// wire status is already 200. Otherwise retain recovered attempts as a
|
||||
// provider-health row whose 2xx status keeps it outside request SLA.
|
||||
if len(service.GetOpsStreamErrors(c)) > 0 {
|
||||
if streamErrs := service.GetOpsStreamErrors(c); len(streamErrs) > 0 {
|
||||
logOpsStreamError(c, ops, status)
|
||||
// 请求级带内结果不承载上游归因,此前尝试的上游错误仍按恢复行记录。
|
||||
if opsStreamErrorsAllRequestScoped(streamErrs) {
|
||||
logOpsRecoveredUpstream(c, ops, status)
|
||||
}
|
||||
} else {
|
||||
logOpsRecoveredUpstream(c, ops, status)
|
||||
}
|
||||
@@ -1405,22 +1409,31 @@ func opsRequestTypeFromContext(c *gin.Context) *int16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// logOpsStreamError 记录一次挂在已固化 HTTP 200 SSE 流上的就地错误。
|
||||
// 由于 wire 状态码停留在 200,常规的 status>=400 捕获路径永远不会触发;
|
||||
// handleStreamingAwareError 通过 service.MarkOpsStreamError 标记这类错误,
|
||||
// 此函数据此补记一条错误日志,让并发限流/流内失败在错误看板里可见。
|
||||
//
|
||||
// 仅在 status<400 且不存在上游错误上下文时调用:上游透传错误已由中间件的
|
||||
// upstream-context 分支落库,无需在此重复记录。
|
||||
// logOpsStreamError 记录挂在 2xx 响应上的带内错误(就地 SSE error 帧、非流式正文里的
|
||||
// 请求级结果等)。由于 wire 状态码停留在 2xx,常规的 status>=400 捕获路径不会触发;
|
||||
// 标记方通过 service.MarkOpsStreamError / MarkOpsStreamErrorValue 登记,此函数据此补记
|
||||
// 错误日志。上游错误上下文(若有)是否参与分类与归因由标记的 RequestScoped 决定。
|
||||
func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) {
|
||||
for _, streamErr := range service.GetOpsStreamErrors(c) {
|
||||
logOpsStreamErrorValue(c, ops, wireStatus, streamErr)
|
||||
}
|
||||
}
|
||||
|
||||
func opsStreamErrorsAllRequestScoped(streamErrs []service.OpsStreamError) bool {
|
||||
if len(streamErrs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, streamErr := range streamErrs {
|
||||
if !streamErr.RequestScoped {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus int, streamErr service.OpsStreamError) {
|
||||
// 命中 skip_monitoring=true 透传规则的请求跳过落库,与其它分支一致。
|
||||
if streamErr.SkipMonitoring || (streamErr.Turn == 0 && shouldSkipFinalOpsFailure(c)) {
|
||||
if streamErr.SkipMonitoring || (streamErr.Turn == 0 && !streamErr.RequestScoped && shouldSkipFinalOpsFailure(c)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1435,9 +1448,19 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
|
||||
classifyStatus = wireStatus
|
||||
}
|
||||
normalizedType := normalizeOpsErrorType(streamErr.ErrType, streamErr.Code)
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, streamErr.Message, streamErr.Code, classifyStatus)
|
||||
var phase, errorOwner, errorSource string
|
||||
var isBusinessLimited bool
|
||||
if streamErr.RequestScoped {
|
||||
// 请求级带内结果只按错误类型分类,此前尝试残留的上游错误上下文不参与判定。
|
||||
phase = classifyOpsPhase(normalizedType, streamErr.Message, streamErr.Code)
|
||||
isBusinessLimited = true
|
||||
errorOwner = classifyOpsErrorOwner(phase, streamErr.Message)
|
||||
errorSource = classifyOpsErrorSource(phase, streamErr.Message)
|
||||
} else {
|
||||
phase, isBusinessLimited, errorOwner, errorSource = classifyOpsErrorLog(c, normalizedType, streamErr.Message, streamErr.Code, classifyStatus)
|
||||
}
|
||||
recordedStatus := wireStatus
|
||||
if streamErr.CountTowardsSLA && streamErr.IntendedStatus >= 400 {
|
||||
if streamErr.IntendedStatus >= 400 && (streamErr.CountTowardsSLA || streamErr.RequestScoped) {
|
||||
recordedStatus = streamErr.IntendedStatus
|
||||
}
|
||||
errorBody := ""
|
||||
@@ -1488,8 +1511,8 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
// 就地 SSE 错误只出现在流式请求上。
|
||||
Stream: true,
|
||||
// 带内错误默认挂在 SSE 流上;NonStream 标记的来自非流式 2xx 响应体。
|
||||
Stream: !streamErr.NonStream,
|
||||
InboundEndpoint: GetInboundEndpoint(c),
|
||||
UpstreamEndpoint: GetUpstreamEndpoint(c, platform),
|
||||
RequestedModel: modelName,
|
||||
@@ -1530,8 +1553,10 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
applyOpsLatencyFieldsFromContext(c, entry)
|
||||
applyOpsUpstreamFieldsFromContext(c, entry)
|
||||
if streamErr.Turn > 0 {
|
||||
if !streamErr.RequestScoped {
|
||||
applyOpsUpstreamFieldsFromContext(c, entry)
|
||||
}
|
||||
if streamErr.Turn > 0 && !streamErr.RequestScoped {
|
||||
applyOpsStreamErrorSnapshot(entry, streamErr)
|
||||
}
|
||||
|
||||
|
||||
@@ -1996,3 +1996,175 @@ func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *tes
|
||||
require.True(t, isBusinessLimited)
|
||||
require.Equal(t, "platform", errorOwner)
|
||||
}
|
||||
|
||||
// 带内错误也可能出现在非流式 2xx 响应体里(如 Gemini generateContent 的 finishReason):
|
||||
// NonStream 标记落库 stream=false,未标记的沿用流式。
|
||||
func TestLogOpsStreamError_NonStreamInBandContentPolicy(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil)
|
||||
setOpsRequestContext(c, "gemini-3.7-flash", false)
|
||||
|
||||
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
|
||||
ErrType: "invalid_request_error",
|
||||
Code: "PROHIBITED_CONTENT",
|
||||
Message: "Gemini content policy stop (finishReason=PROHIBITED_CONTENT): Response stopped due to prohibited content.",
|
||||
IntendedStatus: http.StatusBadRequest,
|
||||
RequestScoped: true,
|
||||
NonStream: true,
|
||||
})
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
logOpsStreamError(c, ops, http.StatusOK)
|
||||
|
||||
job := <-opsErrorLogQueue
|
||||
require.NotNil(t, job.entry)
|
||||
require.False(t, job.entry.Stream)
|
||||
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
|
||||
require.Equal(t, "request", job.entry.ErrorPhase)
|
||||
require.Equal(t, "client", job.entry.ErrorOwner)
|
||||
require.True(t, job.entry.IsBusinessLimited)
|
||||
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode, "请求级带内结果按 IntendedStatus 落库,才能进入错误列表")
|
||||
require.Equal(t, "P3", job.entry.Severity)
|
||||
require.Equal(t, "gemini-3.7-flash", job.entry.Model)
|
||||
require.Contains(t, job.entry.ErrorBody, "PROHIBITED_CONTENT")
|
||||
}
|
||||
|
||||
// 请求级带内结果(RequestScoped):此前尝试残留的上游错误上下文不参与分类、不做上游归因,
|
||||
// 按业务限制计;透传规则的 skip_monitoring 残留也不影响它落库。
|
||||
func TestLogOpsStreamError_RequestScopedIgnoresResidualUpstreamContext(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:streamGenerateContent", nil)
|
||||
setOpsRequestContext(c, "gemini-3.7-flash", true)
|
||||
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusTooManyRequests)
|
||||
c.Set(service.OpsUpstreamErrorMessageKey, "earlier attempt was rate limited")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
UpstreamStatusCode: http.StatusTooManyRequests,
|
||||
Message: "earlier attempt was rate limited",
|
||||
SkipMonitoring: true,
|
||||
}})
|
||||
|
||||
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
|
||||
ErrType: "invalid_request_error",
|
||||
Code: "PROHIBITED_CONTENT",
|
||||
Message: "Gemini content policy stop (finishReason=PROHIBITED_CONTENT): Response stopped due to prohibited content.",
|
||||
IntendedStatus: http.StatusBadRequest,
|
||||
RequestScoped: true,
|
||||
})
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
logOpsStreamError(c, ops, http.StatusOK)
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.NotNil(t, job.entry)
|
||||
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
|
||||
require.Equal(t, "request", job.entry.ErrorPhase)
|
||||
require.Equal(t, "client", job.entry.ErrorOwner)
|
||||
require.Equal(t, "client_request", job.entry.ErrorSource)
|
||||
require.True(t, job.entry.IsBusinessLimited)
|
||||
require.True(t, job.entry.Stream)
|
||||
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
|
||||
require.Nil(t, job.entry.UpstreamStatusCode, "残留的 429 不得贴到内容策略行上")
|
||||
require.Nil(t, job.entry.UpstreamErrorMessage)
|
||||
require.Nil(t, job.entry.UpstreamErrorsJSON)
|
||||
require.Contains(t, job.entry.ErrorBody, "PROHIBITED_CONTENT")
|
||||
}
|
||||
|
||||
// 请求级带内结果与此前尝试的上游错误并存时,中间件既落带内错误行,也保留恢复行的 provider 遥测。
|
||||
func TestOpsErrorLoggerMiddleware_RequestScopedInBandErrorKeepsRecoveredTelemetry(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1beta/models/gemini-3.7-flash:generateContent", func(c *gin.Context) {
|
||||
setOpsRequestContext(c, "gemini-3.7-flash", false)
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
UpstreamStatusCode: http.StatusTooManyRequests,
|
||||
Message: "earlier attempt was rate limited",
|
||||
}})
|
||||
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
|
||||
ErrType: "invalid_request_error",
|
||||
Code: "SAFETY",
|
||||
Message: "Gemini content policy stop (finishReason=SAFETY): Response stopped due to safety reasons.",
|
||||
IntendedStatus: http.StatusBadRequest,
|
||||
RequestScoped: true,
|
||||
NonStream: true,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"candidates": []any{}})
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
require.Equal(t, int64(2), OpsErrorLogQueueLength())
|
||||
inBand := <-opsErrorLogQueue
|
||||
require.Equal(t, "invalid_request_error", inBand.entry.ErrorType)
|
||||
require.Equal(t, "request", inBand.entry.ErrorPhase)
|
||||
require.Equal(t, "client", inBand.entry.ErrorOwner)
|
||||
require.True(t, inBand.entry.IsBusinessLimited)
|
||||
require.False(t, inBand.entry.Stream)
|
||||
require.Equal(t, http.StatusBadRequest, inBand.entry.StatusCode)
|
||||
require.Nil(t, inBand.entry.UpstreamStatusCode)
|
||||
require.Nil(t, inBand.entry.UpstreamErrorsJSON)
|
||||
|
||||
recovered := <-opsErrorLogQueue
|
||||
require.Equal(t, "upstream", recovered.entry.ErrorPhase)
|
||||
require.Equal(t, "provider", recovered.entry.ErrorOwner)
|
||||
require.Equal(t, http.StatusOK, recovered.entry.StatusCode)
|
||||
require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", recovered.entry.ErrorMessage)
|
||||
}
|
||||
|
||||
// 非请求级带内失败(如上游错误信封)仍与既有语义一致:上游上下文参与分类并做归因,只落一行。
|
||||
func TestOpsErrorLoggerMiddleware_UpstreamInBandFailureStillSingleRow(t *testing.T) {
|
||||
setupOpsErrorLogTestQueue(t, 4)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1beta/models/gemini-3.7-flash:streamGenerateContent", func(c *gin.Context) {
|
||||
setOpsRequestContext(c, "gemini-3.7-flash", true)
|
||||
service.SetOpsUpstreamError(c, http.StatusTooManyRequests, "quota", "")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
UpstreamStatusCode: http.StatusTooManyRequests,
|
||||
Message: "quota",
|
||||
Kind: "stream_failed",
|
||||
}})
|
||||
service.MarkOpsStreamFailure(c, "rate_limit_error", "RESOURCE_EXHAUSTED", "quota", http.StatusTooManyRequests)
|
||||
c.Data(http.StatusOK, "text/event-stream", []byte("data: {\"error\":{\"code\":429}}\n\n"))
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:streamGenerateContent", nil))
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
job := <-opsErrorLogQueue
|
||||
require.Equal(t, "rate_limit_error", job.entry.ErrorType)
|
||||
require.Equal(t, "upstream", job.entry.ErrorPhase)
|
||||
require.Equal(t, "provider", job.entry.ErrorOwner)
|
||||
require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
|
||||
require.True(t, job.entry.Stream)
|
||||
require.NotNil(t, job.entry.UpstreamStatusCode)
|
||||
require.Equal(t, http.StatusTooManyRequests, *job.entry.UpstreamStatusCode)
|
||||
}
|
||||
|
||||
// Gemini 带内信号登记使用的错误类型必须都在 ops 白名单里,否则会被归一化成 api_error。
|
||||
func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) {
|
||||
for _, errType := range []string{
|
||||
"invalid_request_error", "authentication_error", "permission_error",
|
||||
"not_found_error", "rate_limit_error", "upstream_error",
|
||||
} {
|
||||
require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
PaymentBalanceDisabled: settings.PaymentBalanceDisabled,
|
||||
Version: h.version,
|
||||
ServerTimezone: timezone.Name(),
|
||||
ServerUTCOffset: timezone.UTCOffset(),
|
||||
@@ -111,6 +112,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: settings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
PlatformZhipu = domain.PlatformZhipu
|
||||
PlatformDeepseek = domain.PlatformDeepseek
|
||||
PlatformMiniMax = domain.PlatformMiniMax
|
||||
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
|
||||
)
|
||||
|
||||
// AllPlatforms 返回所有支持的平台列表
|
||||
@@ -59,6 +60,7 @@ func AllPlatforms() []string {
|
||||
PlatformZhipu,
|
||||
PlatformDeepseek,
|
||||
PlatformMiniMax,
|
||||
PlatformOpenCodeGo,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +17,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
|
||||
"zhipu",
|
||||
"deepseek",
|
||||
"minimax",
|
||||
"opencode_go",
|
||||
}, AllPlatforms())
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
|
||||
"notifyUrl": "https://example.com/notify",
|
||||
"returnUrl": "https://example.com/return",
|
||||
"paymentMode": paymentModePopup,
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
|
||||
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt.trc20","displayName":"USDT-TRC20"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewEasyPay: %v", err)
|
||||
@@ -221,8 +221,8 @@ func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse pay url: %v", err)
|
||||
}
|
||||
if got := payURL.Query().Get("type"); got != "usdt" {
|
||||
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
|
||||
if got := payURL.Query().Get("type"); got != "usdt.trc20" {
|
||||
t.Fatalf("pay url type = %q, want usdt.trc20 (%s)", got, resp.PayURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="))
|
||||
}
|
||||
@@ -184,6 +184,16 @@ var geminiModels = []modelDef{
|
||||
{ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", CreatedAt: "2026-08-13T00:00:00Z"},
|
||||
{ID: "gemini-3.7-flash-high", DisplayName: "Gemini 3.7 Flash High", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.7-flash-low", DisplayName: "Gemini 3.7 Flash Low", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.7-flash-medium", DisplayName: "Gemini 3.7 Flash Medium", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.7-flash-tiered", DisplayName: "Gemini 3.7 Flash", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z"},
|
||||
{ID: "gemini-3.8-flash-high", DisplayName: "Gemini 3.8 Flash High", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash-low", DisplayName: "Gemini 3.8 Flash Low", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash-medium", DisplayName: "Gemini 3.8 Flash Medium", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.8-flash-tiered", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.7-flash-high",
|
||||
"gemini-3.7-flash-low",
|
||||
"gemini-3.7-flash-medium",
|
||||
"gemini-3.7-flash-tiered",
|
||||
"gemini-3.8-flash",
|
||||
"gemini-3.8-flash-high",
|
||||
"gemini-3.8-flash-low",
|
||||
"gemini-3.8-flash-medium",
|
||||
"gemini-3.8-flash-tiered",
|
||||
}
|
||||
|
||||
for _, id := range requiredIDs {
|
||||
|
||||
@@ -88,11 +88,14 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
// 用于存储 tool_use id -> name 映射
|
||||
toolIDToName := make(map[string]string)
|
||||
|
||||
// 检测是否有 web_search 工具
|
||||
hasWebSearchTool := hasWebSearchTool(claudeReq.Tools)
|
||||
// 仅在「只有内置 web_search、没有客户端 function tools」时走 web_search 降级模型。
|
||||
// Antigravity v1internal 不支持内置工具与 functionDeclarations 混用(即使设置
|
||||
// includeServerSideToolInvocations 仍会 400,见 issue #6464),混用时会丢弃内置搜索,
|
||||
// 因此不能再强制切到 gemini-2.5-flash,否则 Codex 等带 shell 工具的请求会整单失败。
|
||||
useWebSearchRequest := hasWebSearchTool(claudeReq.Tools) && !hasClientFunctionTools(claudeReq.Tools)
|
||||
requestType := "agent"
|
||||
targetModel := mappedModel
|
||||
if hasWebSearchTool {
|
||||
if useWebSearchRequest {
|
||||
requestType = "web_search"
|
||||
if targetModel != webSearchFallbackModel {
|
||||
targetModel = webSearchFallbackModel
|
||||
@@ -147,22 +150,16 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
||||
SessionID: generateStableSessionID(contents),
|
||||
}
|
||||
|
||||
// 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig
|
||||
// toolConfig must always be present: upstream rejects requests without it,
|
||||
// including reasoning models called without any tools.
|
||||
// 总是设置 toolConfig,与官方客户端一致
|
||||
// 总是设置 toolConfig,与官方客户端一致。
|
||||
// 注意:buildTools 会在客户端 function tools 存在时丢弃 googleSearch/codeExecution
|
||||
// (issue #6464),因此这里不再注入 includeServerSideToolInvocations。
|
||||
innerRequest.ToolConfig = &GeminiToolConfig{
|
||||
FunctionCallingConfig: &GeminiFunctionCallingConfig{
|
||||
Mode: "VALIDATED",
|
||||
},
|
||||
}
|
||||
// 内置工具(googleSearch)与函数调用混用时,上游要求显式开启
|
||||
// includeServerSideToolInvocations,否则返回 400(issue #5709)。
|
||||
// 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。
|
||||
if hasMixedToolInvocations(tools) {
|
||||
enabled := true
|
||||
innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled
|
||||
}
|
||||
|
||||
if systemInstruction != nil {
|
||||
innerRequest.SystemInstruction = systemInstruction
|
||||
@@ -296,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
|
||||
@@ -308,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
|
||||
@@ -323,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
|
||||
@@ -696,6 +715,20 @@ func hasWebSearchTool(tools []ClaudeTool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hasClientFunctionTools 判断是否存在可转发的客户端 function/custom 工具。
|
||||
// 内置 web_search / code_execution 不算客户端工具。
|
||||
func hasClientFunctionTools(tools []ClaudeTool) bool {
|
||||
for _, tool := range tools {
|
||||
if isWebSearchTool(tool) || isCodeExecutionTool(tool) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(tool.Name) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWebSearchTool(tool ClaudeTool) bool {
|
||||
if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" {
|
||||
return true
|
||||
@@ -714,21 +747,6 @@ func isCodeExecutionTool(tool ClaudeTool) bool {
|
||||
return strings.TrimSpace(tool.Type) == "code_execution"
|
||||
}
|
||||
|
||||
// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具
|
||||
// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。
|
||||
func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool {
|
||||
hasFunc, hasBuiltin := false, false
|
||||
for _, d := range declarations {
|
||||
if len(d.FunctionDeclarations) > 0 {
|
||||
hasFunc = true
|
||||
}
|
||||
if d.GoogleSearch != nil || d.CodeExecution != nil {
|
||||
hasBuiltin = true
|
||||
}
|
||||
}
|
||||
return hasFunc && hasBuiltin
|
||||
}
|
||||
|
||||
// buildTools 构建 tools
|
||||
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
||||
if len(tools) == 0 {
|
||||
@@ -794,6 +812,18 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
||||
})
|
||||
}
|
||||
|
||||
// Antigravity v1internal 协议不支持内置工具与 functionDeclarations 混用:
|
||||
// 即便带上 includeServerSideToolInvocations 仍返回 400(issue #6464)。
|
||||
// Codex 默认同时带 web_search 与 shell 等客户端工具,优先保留客户端工具,
|
||||
// 使代理会话可继续,而不是整单 upstream_error。
|
||||
if len(funcDecls) > 0 {
|
||||
if hasWebSearch || hasCodeExecution {
|
||||
log.Printf("[antigravity] dropping built-in tools (web_search/code_execution) because client function tools are present; Antigravity v1internal rejects the mix")
|
||||
}
|
||||
hasWebSearch = false
|
||||
hasCodeExecution = false
|
||||
}
|
||||
|
||||
var declarations []GeminiToolDeclaration
|
||||
if len(funcDecls) > 0 {
|
||||
declarations = append(declarations, GeminiToolDeclaration{
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
|
||||
func TestBuildTools_DropsWebSearchWhenClientFunctionsPresent(t *testing.T) {
|
||||
tools := []ClaudeTool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
@@ -277,13 +277,27 @@ func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
|
||||
}
|
||||
|
||||
result := buildTools(tools)
|
||||
require.Len(t, result, 2)
|
||||
// Antigravity v1internal rejects mixed built-in + functionDeclarations (#6464).
|
||||
require.Len(t, result, 1)
|
||||
require.Len(t, result[0].FunctionDeclarations, 1)
|
||||
require.Equal(t, "get_weather", result[0].FunctionDeclarations[0].Name)
|
||||
require.NotNil(t, result[1].GoogleSearch)
|
||||
require.NotNil(t, result[1].GoogleSearch.EnhancedContent)
|
||||
require.NotNil(t, result[1].GoogleSearch.EnhancedContent.ImageSearch)
|
||||
require.Equal(t, 5, result[1].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount)
|
||||
require.Nil(t, result[0].GoogleSearch)
|
||||
}
|
||||
|
||||
func TestBuildTools_KeepsWebSearchWhenNoClientFunctions(t *testing.T) {
|
||||
tools := []ClaudeTool{
|
||||
{
|
||||
Type: "web_search_20250305",
|
||||
Name: "web_search",
|
||||
},
|
||||
}
|
||||
|
||||
result := buildTools(tools)
|
||||
require.Len(t, result, 1)
|
||||
require.NotNil(t, result[0].GoogleSearch)
|
||||
require.NotNil(t, result[0].GoogleSearch.EnhancedContent)
|
||||
require.NotNil(t, result[0].GoogleSearch.EnhancedContent.ImageSearch)
|
||||
require.Equal(t, 5, result[0].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount)
|
||||
}
|
||||
|
||||
func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
|
||||
@@ -555,18 +569,22 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions
|
||||
},
|
||||
}
|
||||
|
||||
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
|
||||
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-6", DefaultTransformOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
var req V1InternalRequest
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
require.Len(t, req.Request.Tools, 2)
|
||||
// Antigravity v1internal cannot mix googleSearch with functionDeclarations
|
||||
// (#6464). Prefer client tools so Codex-style sessions keep working.
|
||||
require.Equal(t, "agent", req.RequestType)
|
||||
require.Equal(t, "claude-sonnet-4-6", req.Model)
|
||||
require.Len(t, req.Request.Tools, 1)
|
||||
require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1)
|
||||
require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name)
|
||||
require.NotNil(t, req.Request.Tools[1].GoogleSearch)
|
||||
require.Nil(t, req.Request.Tools[0].GoogleSearch)
|
||||
}
|
||||
|
||||
func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
|
||||
func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T) {
|
||||
functionTool := ClaudeTool{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather information",
|
||||
@@ -577,7 +595,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
|
||||
Name: "web_search",
|
||||
}
|
||||
|
||||
transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) {
|
||||
transform := func(t *testing.T, tools []ClaudeTool, mappedModel string) (V1InternalRequest, string) {
|
||||
t.Helper()
|
||||
body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{
|
||||
Model: "claude-3-5-sonnet-latest",
|
||||
@@ -588,7 +606,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Tools: tools,
|
||||
}, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
|
||||
}, "project-1", mappedModel, DefaultTransformOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
var req V1InternalRequest
|
||||
@@ -596,27 +614,33 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
|
||||
return req, string(body)
|
||||
}
|
||||
|
||||
t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool})
|
||||
t.Run("mixed builtin and function tools keep only client functions", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool}, "claude-sonnet-4-6")
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.Contains(t, raw, `"includeServerSideToolInvocations":true`)
|
||||
require.Equal(t, "agent", req.RequestType)
|
||||
require.Equal(t, "claude-sonnet-4-6", req.Model)
|
||||
require.Len(t, req.Request.Tools, 1)
|
||||
require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1)
|
||||
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.NotContains(t, raw, "googleSearch")
|
||||
require.NotContains(t, raw, "includeServerSideToolInvocations")
|
||||
})
|
||||
|
||||
t.Run("function tools only leave the flag unset", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{functionTool})
|
||||
req, raw := transform(t, []ClaudeTool{functionTool}, "gemini-2.5-flash")
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.NotContains(t, raw, "includeServerSideToolInvocations")
|
||||
})
|
||||
|
||||
t.Run("web search only leaves the flag unset", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{webSearchTool})
|
||||
t.Run("web search only keeps googleSearch and fallback model", func(t *testing.T) {
|
||||
req, raw := transform(t, []ClaudeTool{webSearchTool}, "claude-sonnet-4-6")
|
||||
|
||||
require.NotNil(t, req.Request.ToolConfig)
|
||||
require.Equal(t, "web_search", req.RequestType)
|
||||
require.Equal(t, "gemini-2.5-flash", req.Model)
|
||||
require.Len(t, req.Request.Tools, 1)
|
||||
require.NotNil(t, req.Request.Tools[0].GoogleSearch)
|
||||
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
|
||||
require.NotContains(t, raw, "includeServerSideToolInvocations")
|
||||
})
|
||||
|
||||
@@ -339,7 +339,63 @@ func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil
|
||||
normalized := normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID)
|
||||
return normalizeResponsesDerivedChatMessageRoles(normalized), nil
|
||||
}
|
||||
|
||||
// normalizeResponsesDerivedChatMessageRoles rewrites the Chat Completions
|
||||
// message list produced by the Responses bridge so that strict upstreams which
|
||||
// only accept system content at the very start of the conversation accept it.
|
||||
//
|
||||
// The bridge turns the Responses `instructions` field and every role:"developer"
|
||||
// item into a system message. Codex always sends both instructions and a leading
|
||||
// developer item, and it also injects developer notices into the middle of the
|
||||
// message history (for example when the user switches models). Forwarded as-is
|
||||
// that produces two leading system messages and mid-conversation system
|
||||
// messages, which Qwen-family upstreams reject with
|
||||
// "System message must be at the beginning." (HTTP 400).
|
||||
//
|
||||
// Leading system/developer messages are therefore merged into a single leading
|
||||
// system message, while later ones keep their position but are downgraded to
|
||||
// user messages so the same text still reaches the model.
|
||||
func normalizeResponsesDerivedChatMessageRoles(messages []ChatMessage) []ChatMessage {
|
||||
isInstructionRole := func(role string) bool {
|
||||
return role == "system" || role == "developer"
|
||||
}
|
||||
|
||||
leading := 0
|
||||
for leading < len(messages) && isInstructionRole(messages[leading].Role) {
|
||||
leading++
|
||||
}
|
||||
|
||||
out := make([]ChatMessage, 0, len(messages))
|
||||
switch leading {
|
||||
case 0:
|
||||
// No leading instructions, nothing to merge.
|
||||
case 1:
|
||||
// A single leading prompt is already valid; keep its content byte for
|
||||
// byte instead of round-tripping it through the text merge below.
|
||||
out = append(out, messages[0])
|
||||
default:
|
||||
merged := make([]string, 0, leading)
|
||||
for _, m := range messages[:leading] {
|
||||
if text := strings.TrimSpace(chatMessageContentText(m.Content)); text != "" {
|
||||
merged = append(merged, text)
|
||||
}
|
||||
}
|
||||
if len(merged) > 0 {
|
||||
content, _ := json.Marshal(strings.Join(merged, "\n\n"))
|
||||
out = append(out, ChatMessage{Role: "system", Content: content})
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range messages[leading:] {
|
||||
if isInstructionRole(m.Role) {
|
||||
m.Role = "user"
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildChatMessagesFromItems walks the Responses input items and appends the
|
||||
@@ -523,6 +579,21 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
})
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
case "agent_message":
|
||||
// Codex multi_agent_v2 用 agent_message 在父线程与子智能体之间传递任务和回复:
|
||||
// input_text 是信封(消息类型、任务名、发送者),正文放在 encrypted_content 片段里
|
||||
// (自定义 provider 下为明文)。chat 上游没有对应条目,按原顺序拼成一条 user 消息,
|
||||
// 否则子智能体收不到任务却仍返回 200。
|
||||
text := agentMessageText(item["content"])
|
||||
if text == "" {
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
}
|
||||
content, _ := json.Marshal(text)
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
pendingReasoning = ""
|
||||
lastTurnReasoning = ""
|
||||
continue
|
||||
case "input_text", "text":
|
||||
content, _ := json.Marshal(rawString(item["text"]))
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
@@ -582,6 +653,32 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
return messages, mediaByCallID, nil
|
||||
}
|
||||
|
||||
// agentMessageText 按原顺序拼接 agent_message 里 input_text 与 encrypted_content 片段的文本。
|
||||
func agentMessageText(raw json.RawMessage) string {
|
||||
raw = bytesTrimSpace(raw)
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
var parts []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, part := range parts {
|
||||
switch rawString(part["type"]) {
|
||||
case "input_text", "text":
|
||||
_, _ = b.WriteString(rawString(part["text"]))
|
||||
case "encrypted_content":
|
||||
_, _ = b.WriteString(rawString(part["encrypted_content"]))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// extractToolOutputMedia rewrites only recognized image nodes. Media-free
|
||||
// outputs return rewritten=false so the caller can preserve their original
|
||||
// bytes and prompt-cache prefix.
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #6653:Codex 把运行时工具放在 input[].additional_tools 里,force_chat_completions
|
||||
// 下必须仍降级成标准 chat 工具随 /v1/chat/completions 发出,而不是因无法转换退回 Responses。
|
||||
func TestResponsesToChatCompletionsRequest_LowersAdditionalToolsForChatOnlyUpstream(t *testing.T) {
|
||||
body := `{
|
||||
"model":"gpt-5.6-luna","stream":true,"store":false,
|
||||
"reasoning":{"effort":"medium","context":"all_turns"},
|
||||
"tool_choice":"auto","parallel_tool_calls":false,
|
||||
"include":["reasoning.encrypted_content"],
|
||||
"input":[
|
||||
{"type":"additional_tools","role":"developer","tools":[
|
||||
{"type":"custom","name":"exec","description":"run a shell command","format":{"type":"grammar","syntax":"lark","definition":"start: /.*/"}},
|
||||
{"type":"function","name":"wait","strict":false,"parameters":{"type":"object"}},
|
||||
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
|
||||
]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"reply ok"}]}
|
||||
]
|
||||
}`
|
||||
var req ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &req))
|
||||
|
||||
tools, err := EffectiveResponsesTools(&req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tools, 3, "additional_tools 里的 custom / function / namespace 都要被提升")
|
||||
require.True(t, CustomToolNames(tools)["exec"])
|
||||
require.Equal(t, "collaboration", NamespaceToolNames(tools)["collaboration__spawn_agent"].Namespace)
|
||||
|
||||
chat, err := ResponsesToChatCompletionsRequest(&req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chat.Messages, 1, "additional_tools 不能变成 chat 消息")
|
||||
require.Equal(t, "user", chat.Messages[0].Role)
|
||||
|
||||
names := make([]string, 0, len(chat.Tools))
|
||||
for _, tool := range chat.Tools {
|
||||
require.Equal(t, "function", tool.Type, "chat 上游只认 function 工具")
|
||||
require.NotNil(t, tool.Function)
|
||||
names = append(names, tool.Function.Name)
|
||||
}
|
||||
require.Equal(t, []string{"exec", "wait", "collaboration__spawn_agent"}, names)
|
||||
require.JSONEq(t, customToolInputSchema, string(chat.Tools[0].Function.Parameters), "custom 工具降级为单一 input 字符串参数")
|
||||
require.NotNil(t, chat.ParallelToolCalls)
|
||||
require.False(t, *chat.ParallelToolCalls)
|
||||
require.Equal(t, "medium", chat.ReasoningEffort)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #6887:Codex multi_agent_v2 用 agent_message 条目在父线程与子智能体之间传递任务和回复,
|
||||
// 任务正文放在 encrypted_content 片段里(自定义 provider 下为明文)。chat 上游没有对应条目,
|
||||
// 桥必须把它按原顺序拼成一条 user 消息,否则子智能体收不到任务。
|
||||
func TestResponsesToChatCompletionsRequest_AgentMessageBecomesUserMessage(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "glm-5.3",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>cwd</environment_context>"}]},
|
||||
{"type":"agent_message","id":"amsg_1","author":"/root","recipient":"/root/alpha_task","content":[
|
||||
{"type":"input_text","text":"Message Type: NEW_TASK\nTask name: /root/alpha_task\nSender: /root\nPayload:\n"},
|
||||
{"type":"encrypted_content","encrypted_content":"Reply with the single word: ALPHA"}
|
||||
]},
|
||||
{"type":"reasoning","summary":[{"type":"summary_text","text":"just answer"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ALPHA"}]},
|
||||
{"type":"agent_message","id":"amsg_2","author":"/root/alpha_task","recipient":"/root","content":[
|
||||
{"type":"input_text","text":"Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/alpha_task\nPayload:\nALPHA"}
|
||||
]}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 4)
|
||||
|
||||
require.Equal(t, "user", out.Messages[0].Role)
|
||||
require.Equal(t, "user", out.Messages[1].Role, "父线程发来的任务要成为 user 消息")
|
||||
require.JSONEq(t, `"Message Type: NEW_TASK\nTask name: /root/alpha_task\nSender: /root\nPayload:\nReply with the single word: ALPHA"`, string(out.Messages[1].Content), "信封与 encrypted_content 里的正文要按原顺序拼接")
|
||||
require.Equal(t, "assistant", out.Messages[2].Role)
|
||||
require.JSONEq(t, `"ALPHA"`, string(out.Messages[2].Content))
|
||||
require.Equal(t, "user", out.Messages[3].Role, "子智能体回给父线程的消息同样是 user 消息")
|
||||
require.JSONEq(t, `"Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/alpha_task\nPayload:\nALPHA"`, string(out.Messages[3].Content))
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_AgentMessageWithoutTextIsSkipped(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "glm-5.3",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"type":"agent_message","author":"/root","recipient":"/root/a","content":[]},
|
||||
{"type":"agent_message","author":"/root","recipient":"/root/a"},
|
||||
{"type":"agent_message","author":"/root","recipient":"/root/a","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 1, "没有文本正文的 agent_message 不产生消息")
|
||||
require.Equal(t, "user", out.Messages[0].Role)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestResponsesInputToChatMessages_EmptyRoleFallsBackToUser(t *testing.T) {
|
||||
assert.Equal(t, "user", messages[0].Role)
|
||||
}
|
||||
|
||||
func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *testing.T) {
|
||||
func TestResponsesInputToChatMessages_LeadingDeveloperRolesMergeIntoOneSystem(t *testing.T) {
|
||||
input := json.RawMessage(`[
|
||||
{"role":" Developer ","content":"one"},
|
||||
{"role":"\tDEVELOPER\n","content":"two"}
|
||||
@@ -104,9 +104,10 @@ func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *tes
|
||||
|
||||
messages, err := responsesInputToChatMessages("", input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 2)
|
||||
require.Len(t, messages, 1)
|
||||
|
||||
assert.Equal(t, []string{"system", "system"}, chatMessageRoles(messages))
|
||||
assert.Equal(t, []string{"system"}, chatMessageRoles(messages))
|
||||
assert.JSONEq(t, `"one\n\ntwo"`, string(messages[0].Content))
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *testing.T) {
|
||||
@@ -121,12 +122,29 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Messages, 3)
|
||||
require.Len(t, out.Messages, 2)
|
||||
|
||||
assert.Equal(t, []string{"system", "system", "user"}, chatMessageRoles(out.Messages))
|
||||
assert.JSONEq(t, `"Use concise answers."`, string(out.Messages[0].Content))
|
||||
assert.JSONEq(t, `"Prefer JSON."`, string(out.Messages[1].Content))
|
||||
assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content))
|
||||
assert.Equal(t, []string{"system", "user"}, chatMessageRoles(out.Messages))
|
||||
assert.JSONEq(t, `"Use concise answers.\n\nPrefer JSON."`, string(out.Messages[0].Content))
|
||||
assert.JSONEq(t, `"Hello"`, string(out.Messages[1].Content))
|
||||
}
|
||||
|
||||
func TestResponsesInputToChatMessages_MidConversationDeveloperBecomesUser(t *testing.T) {
|
||||
input := json.RawMessage(`[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"<model_switch> switched model"}]},
|
||||
{"type":"message","role":"system","content":[{"type":"input_text","text":"be terse"}]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]`)
|
||||
|
||||
messages, err := responsesInputToChatMessages("", input)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 5)
|
||||
|
||||
assert.Equal(t, []string{"user", "assistant", "user", "user", "user"}, chatMessageRoles(messages))
|
||||
assert.JSONEq(t, `"<model_switch> switched model"`, string(messages[2].Content))
|
||||
assert.JSONEq(t, `"be terse"`, string(messages[3].Content))
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) {
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing
|
||||
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
|
||||
]`)
|
||||
|
||||
require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
|
||||
require.Equal(t, []string{"assistant", "tool", "user", "user", "user"}, chatMessageRoles(messages))
|
||||
require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
|
||||
require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
|
||||
require.JSONEq(t, `"continue"`, string(messages[4].Content))
|
||||
|
||||
@@ -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(`{
|
||||
|
||||
@@ -170,6 +170,12 @@ func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage {
|
||||
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// responsesTextPart identifies one output_text part of a streamed response.
|
||||
type responsesTextPart struct {
|
||||
OutputIndex int
|
||||
ContentIndex int
|
||||
}
|
||||
|
||||
// ResponsesEventToAnthropicState tracks state for converting a sequence of
|
||||
// Responses SSE events directly into Anthropic SSE events.
|
||||
type ResponsesEventToAnthropicState struct {
|
||||
@@ -190,6 +196,13 @@ type ResponsesEventToAnthropicState struct {
|
||||
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
|
||||
OutputIndexToBlockIdx map[int]int
|
||||
|
||||
// textByPart records the text already delivered for each output_text part
|
||||
// so that a done payload can be reconciled against it. It outlives the
|
||||
// content block; closeCurrentBlock must not reset it.
|
||||
textByPart map[responsesTextPart]*strings.Builder
|
||||
// textDelivered records whether any assistant text reached the client.
|
||||
textDelivered bool
|
||||
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
CacheReadInputTokens int
|
||||
@@ -204,6 +217,7 @@ type ResponsesEventToAnthropicState struct {
|
||||
func NewResponsesEventToAnthropicState() *ResponsesEventToAnthropicState {
|
||||
return &ResponsesEventToAnthropicState{
|
||||
OutputIndexToBlockIdx: make(map[int]int),
|
||||
textByPart: make(map[responsesTextPart]*strings.Builder),
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
@@ -222,7 +236,7 @@ func ResponsesEventToAnthropicEvents(
|
||||
case "response.output_text.delta":
|
||||
return resToAnthHandleTextDelta(evt, state)
|
||||
case "response.output_text.done":
|
||||
return resToAnthHandleBlockDone(state)
|
||||
return resToAnthHandleTextDone(evt, state)
|
||||
case "response.function_call_arguments.delta",
|
||||
// custom/freeform 工具的输入增量与 function_call 参数增量同形。
|
||||
"response.custom_tool_call_input.delta":
|
||||
@@ -390,7 +404,18 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
|
||||
}
|
||||
|
||||
func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if evt.Delta == "" {
|
||||
return resToAnthEmitText(evt.Delta, resToAnthTextPartOf(evt), state)
|
||||
}
|
||||
|
||||
func resToAnthTextPartOf(evt *ResponsesStreamEvent) responsesTextPart {
|
||||
return responsesTextPart{OutputIndex: evt.OutputIndex, ContentIndex: evt.ContentIndex}
|
||||
}
|
||||
|
||||
// resToAnthEmitText opens a text block when needed, emits text, and records it
|
||||
// against its part so that a later payload for the same part is reconciled
|
||||
// against what the client already received.
|
||||
func resToAnthEmitText(text string, part responsesTextPart, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -413,18 +438,63 @@ func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventTo
|
||||
})
|
||||
}
|
||||
|
||||
delivered, ok := state.textByPart[part]
|
||||
if !ok {
|
||||
delivered = &strings.Builder{}
|
||||
state.textByPart[part] = delivered
|
||||
}
|
||||
_, _ = delivered.WriteString(text)
|
||||
state.textDelivered = true
|
||||
|
||||
idx := state.ContentBlockIndex
|
||||
events = append(events, AnthropicStreamEvent{
|
||||
Type: "content_block_delta",
|
||||
Index: &idx,
|
||||
Delta: &AnthropicDelta{
|
||||
Type: "text_delta",
|
||||
Text: evt.Delta,
|
||||
Text: text,
|
||||
},
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// resToAnthRecoverText emits the tail of a finished text payload that never
|
||||
// reached the client. Streamed text cannot be recalled, so a payload that does
|
||||
// not extend what was already delivered is left alone.
|
||||
func resToAnthRecoverText(text string, part responsesTextPart, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
builder, known := state.textByPart[part]
|
||||
if !known && state.textDelivered {
|
||||
// The payload is indexed differently from every delta seen so far, so
|
||||
// which part it finishes cannot be established. Recovering it could
|
||||
// repeat an answer the client already has, which is worse than leaving
|
||||
// a partially delivered one alone.
|
||||
return nil
|
||||
}
|
||||
|
||||
var delivered string
|
||||
if known {
|
||||
delivered = builder.String()
|
||||
}
|
||||
if text == delivered || !strings.HasPrefix(text, delivered) {
|
||||
return nil
|
||||
}
|
||||
return resToAnthEmitText(text[len(delivered):], part, state)
|
||||
}
|
||||
|
||||
// resToAnthHandleTextDone recovers text that upstream carried only on the done
|
||||
// event before closing the block, which some streams use instead of sending
|
||||
// output_text.delta at all.
|
||||
func resToAnthHandleTextDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if state.MessageStopSent {
|
||||
// The message is already terminated; a late payload cannot be delivered
|
||||
// without emitting a content block after message_stop.
|
||||
return resToAnthHandleBlockDone(state)
|
||||
}
|
||||
|
||||
events := resToAnthRecoverText(evt.Text, resToAnthTextPartOf(evt), state)
|
||||
return append(events, resToAnthHandleBlockDone(state)...)
|
||||
}
|
||||
|
||||
func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if evt.Delta == "" {
|
||||
return nil
|
||||
@@ -622,6 +692,38 @@ func resToAnthHandleWebSearchDone(evt *ResponsesStreamEvent, state *ResponsesEve
|
||||
return events
|
||||
}
|
||||
|
||||
// resToAnthRecoverTerminalText emits assistant text that only ever appeared in
|
||||
// the terminal response payload, which some streams populate without sending
|
||||
// any output_text event.
|
||||
//
|
||||
// It only runs when no text at all reached the client. Streamed events and the
|
||||
// terminal output array carry no guaranteed common identity, so reconciling
|
||||
// them part by part risks repeating an answer the client already has, which is
|
||||
// worse than leaving a partially streamed response as it is.
|
||||
func resToAnthRecoverTerminalText(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if state.textDelivered || evt.Response == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var events []AnthropicStreamEvent
|
||||
for outputIndex, item := range evt.Response.Output {
|
||||
if item.Type != "message" {
|
||||
continue
|
||||
}
|
||||
for contentIndex, content := range item.Content {
|
||||
if content.Type != "output_text" {
|
||||
continue
|
||||
}
|
||||
part := responsesTextPart{OutputIndex: outputIndex, ContentIndex: contentIndex}
|
||||
events = append(events, resToAnthEmitText(content.Text, part, state)...)
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
events = append(events, closeCurrentBlock(state)...)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
|
||||
if state.MessageStopSent {
|
||||
return nil
|
||||
@@ -629,6 +731,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
|
||||
|
||||
var events []AnthropicStreamEvent
|
||||
events = append(events, closeCurrentBlock(state)...)
|
||||
events = append(events, resToAnthRecoverTerminalText(evt, state)...)
|
||||
|
||||
stopReason := "end_turn"
|
||||
if evt.Usage != nil {
|
||||
|
||||
@@ -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,532 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// feedResponsesEvents converts a whole event sequence with a single state.
|
||||
func feedResponsesEvents(events ...*ResponsesStreamEvent) []AnthropicStreamEvent {
|
||||
state := NewResponsesEventToAnthropicState()
|
||||
var out []AnthropicStreamEvent
|
||||
for _, evt := range events {
|
||||
out = append(out, ResponsesEventToAnthropicEvents(evt, state)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectAnthropicText accumulates the visible assistant text a Claude client
|
||||
// reconstructs from a converted event stream.
|
||||
func collectAnthropicText(events []AnthropicStreamEvent) string {
|
||||
text := ""
|
||||
for _, event := range events {
|
||||
if event.Type == "content_block_delta" && event.Delta != nil && event.Delta.Type == "text_delta" {
|
||||
text += event.Delta.Text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// requireAnthropicBlockLifecycle checks the block framing a strict client
|
||||
// relies on: indices start at zero and increase by one, every block is closed,
|
||||
// and no delta arrives outside an open block.
|
||||
func requireAnthropicBlockLifecycle(t *testing.T, events []AnthropicStreamEvent) {
|
||||
t.Helper()
|
||||
|
||||
open := false
|
||||
next := 0
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "content_block_start":
|
||||
require.False(t, open, "content_block_start while a block is open")
|
||||
require.NotNil(t, event.Index)
|
||||
require.Equal(t, next, *event.Index, "content block indices must increase by one")
|
||||
open = true
|
||||
case "content_block_delta":
|
||||
require.True(t, open, "content_block_delta outside an open block")
|
||||
require.NotNil(t, event.Index)
|
||||
require.Equal(t, next, *event.Index)
|
||||
case "content_block_stop":
|
||||
require.True(t, open, "content_block_stop without an open block")
|
||||
require.NotNil(t, event.Index)
|
||||
require.Equal(t, next, *event.Index)
|
||||
open = false
|
||||
next++
|
||||
}
|
||||
}
|
||||
require.False(t, open, "stream ended with an unclosed content block")
|
||||
}
|
||||
|
||||
func responsesCreated() *ResponsesStreamEvent {
|
||||
return &ResponsesStreamEvent{
|
||||
Type: "response.created",
|
||||
Response: &ResponsesResponse{ID: "resp_text_recovery", Model: "gpt-5.2"},
|
||||
}
|
||||
}
|
||||
|
||||
func responsesMessageOutput(texts ...string) []ResponsesOutput {
|
||||
content := make([]ResponsesContentPart, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
content = append(content, ResponsesContentPart{Type: "output_text", Text: text})
|
||||
}
|
||||
return []ResponsesOutput{{Type: "message", Role: "assistant", Content: content}}
|
||||
}
|
||||
|
||||
// Some streams carry the finished text only on response.output_text.done and
|
||||
// send no output_text.delta at all. Dropping it leaves a protocol-valid message
|
||||
// with no text, which clients report as an empty response.
|
||||
func TestResponsesEventToAnthropicEvents_RecoversTextFromDoneEvent(t *testing.T) {
|
||||
const answer = "recovered from done"
|
||||
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_item.added", Item: &ResponsesOutput{Type: "message"}},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: answer},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, answer, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, "message_stop", events[len(events)-1].Type)
|
||||
}
|
||||
|
||||
// When nothing streamed at all, the terminal event still carries the finished
|
||||
// output. Dropping it produces the same empty turn.
|
||||
func TestResponsesEventToAnthropicEvents_RecoversTextFromTerminalOutput(t *testing.T) {
|
||||
const answer = "recovered from terminal"
|
||||
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput(answer),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, answer, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, "message_stop", events[len(events)-1].Type)
|
||||
}
|
||||
|
||||
// The normal path delivers text through deltas. Neither the done payload nor
|
||||
// the terminal payload may repeat it.
|
||||
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextDeliveredByDeltas(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hel"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "lo"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput("Hello"),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Text recovered from a done event must not be repeated by the terminal
|
||||
// payload that also carries it.
|
||||
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextRecoveredFromDoneEvent(t *testing.T) {
|
||||
const answer = "recovered once"
|
||||
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: answer},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput(answer),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, answer, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Deduplication is per output_text part, not per content block: an event that
|
||||
// closes the block between the deltas and the matching done must not make the
|
||||
// done payload look undelivered.
|
||||
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextWhenBlockClosedBeforeDone(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"},
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 1,
|
||||
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// When only part of the text streamed, the done payload is authoritative and
|
||||
// the missing suffix must still reach the client.
|
||||
func TestResponsesEventToAnthropicEvents_AppendsOnlySuffixMissingFromDeltas(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hel"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello world"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput("Hello world"),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello world", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Streamed text cannot be recalled, so a payload that contradicts what was
|
||||
// already delivered is left alone rather than appended.
|
||||
func TestResponsesEventToAnthropicEvents_IgnoresDonePayloadDivergingFromDeltas(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Goodbye"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Streamed events and the terminal output array share no guaranteed identity,
|
||||
// so once any text has been delivered the terminal payload is left alone. An
|
||||
// upstream that numbers its streamed items differently from its terminal array
|
||||
// must not make a delivered answer look undelivered.
|
||||
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateWhenTerminalOutputIsIndexedDifferently(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "reasoning", ID: "rs_1"},
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 1, Delta: "Hello"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
// The reasoning item is absent here, so the message sits at array
|
||||
// position 0 while it streamed at output_index 1.
|
||||
Output: responsesMessageOutput("Hello"),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Partially streamed responses keep exactly what streamed. Recovering the rest
|
||||
// would mean matching two index spaces that upstreams do not keep aligned.
|
||||
func TestResponsesEventToAnthropicEvents_LeavesTerminalOutputAloneOnceTextStreamed(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", ContentIndex: 0, Delta: "first"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 0, Text: "first"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput("first", "second"),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "first", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// When nothing streamed, every output_text part of the terminal payload is
|
||||
// recovered, merged into one block the way contiguous streamed text is.
|
||||
func TestResponsesEventToAnthropicEvents_RecoversEveryTerminalPartWhenNothingStreamed(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput("first", "second"),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "firstsecond", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
|
||||
starts := 0
|
||||
for _, event := range events {
|
||||
if event.Type == "content_block_start" {
|
||||
starts++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, starts, "contiguous recovered text belongs in one block")
|
||||
}
|
||||
|
||||
// A turn that produced only a tool call must stay text-free: the terminal
|
||||
// payload carries no message item and nothing may be synthesised.
|
||||
func TestResponsesEventToAnthropicEvents_LeavesToolOnlyTurnTextFree(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
|
||||
},
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.function_call_arguments.done",
|
||||
OutputIndex: 0,
|
||||
Arguments: `{"id":1}`,
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{{Type: "function_call", CallID: "call_1", Name: "lookup", Arguments: `{"id":1}`}},
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Empty(t, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Recovery must behave the same on every terminal alias the converter accepts,
|
||||
// not only on response.completed.
|
||||
func TestResponsesEventToAnthropicEvents_RecoversTextOnEveryTerminalAlias(t *testing.T) {
|
||||
for _, terminal := range []string{
|
||||
"response.completed",
|
||||
"response.done",
|
||||
"response.incomplete",
|
||||
"response.failed",
|
||||
} {
|
||||
t.Run(terminal, func(t *testing.T) {
|
||||
const answer = "recovered on a terminal alias"
|
||||
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: terminal, Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput(answer),
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, answer, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, "message_stop", events[len(events)-1].Type)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A done payload indexed differently from every delta seen so far cannot be
|
||||
// matched to a part. Emitting it anyway would repeat an answer the client
|
||||
// already has, which is the failure the terminal guard also exists to prevent.
|
||||
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateWhenDoneIsIndexedDifferently(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
done *ResponsesStreamEvent
|
||||
}{
|
||||
{"content index diverges", &ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 1, Text: "Hello world"}},
|
||||
{"output index diverges", &ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "Hello world"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello world"},
|
||||
tc.done,
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello world", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The same rule holds when only part of the text streamed: an unmatchable
|
||||
// payload is left alone rather than appended on top of what was delivered.
|
||||
func TestResponsesEventToAnthropicEvents_LeavesUnmatchableDoneAloneAfterPartialDeltas(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", ContentIndex: 0, Delta: "Hello"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 1, Text: "Hello world"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "Hello", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Delivered text is keyed by output index and content index together. Keying on
|
||||
// either alone would make one of these parts look already delivered.
|
||||
func TestResponsesEventToAnthropicEvents_KeysDeliveredTextByBothIndices(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, Delta: "first"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 0, ContentIndex: 0, Text: "first"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 1, Delta: "second"},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 0, ContentIndex: 1, Text: "second-tail"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "firstsecond-tail", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// A stray done event after the terminal one must not resurrect a stopped
|
||||
// message. The baseline dropped it outright; recovery has to keep that, or a
|
||||
// client sees a content block after message_stop.
|
||||
func TestResponsesEventToAnthropicEvents_IgnoresDoneEventAfterMessageStop(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: responsesMessageOutput("the answer"),
|
||||
}},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 7, Text: "late text"},
|
||||
)
|
||||
|
||||
assert.Equal(t, "the answer", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, "message_stop", events[len(events)-1].Type, "no content may follow message_stop")
|
||||
}
|
||||
|
||||
// The tool path reads arguments, never text. A tool done event carrying a
|
||||
// stray text field must not produce a text block.
|
||||
func TestResponsesEventToAnthropicEvents_LeavesToolArgumentsDoneTextFree(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
|
||||
},
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.function_call_arguments.done",
|
||||
OutputIndex: 0,
|
||||
Arguments: `{"id":1}`,
|
||||
Text: "must not be emitted",
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Empty(t, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Recovery closes whatever block is open through the shared helper, which owns
|
||||
// signature emission. A thinking block must still get its signature_delta
|
||||
// before its stop.
|
||||
func TestResponsesEventToAnthropicEvents_PreservesThinkingSignatureWhenRecovering(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "reasoning", EncryptedContent: "sig-abc"},
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "recovered"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "recovered", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
|
||||
var signatures, stopsBefore int
|
||||
for _, event := range events {
|
||||
if event.Type == "content_block_delta" && event.Delta != nil && event.Delta.Type == "signature_delta" {
|
||||
assert.Equal(t, "sig-abc", event.Delta.Signature)
|
||||
signatures++
|
||||
assert.Equal(t, 0, stopsBefore, "signature_delta must precede the thinking block stop")
|
||||
}
|
||||
if event.Type == "content_block_stop" {
|
||||
stopsBefore++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, signatures, "the thinking signature must survive recovery")
|
||||
}
|
||||
|
||||
// A stream that ends without any terminal event exits through the synthetic
|
||||
// finalizer, which must still close a block recovery opened.
|
||||
func TestResponsesEventToAnthropicEvents_RecoveredTextSurvivesTheSyntheticFinalizer(t *testing.T) {
|
||||
state := NewResponsesEventToAnthropicState()
|
||||
var events []AnthropicStreamEvent
|
||||
for _, evt := range []*ResponsesStreamEvent{
|
||||
responsesCreated(),
|
||||
{Type: "response.output_text.done", Text: "recovered before the stream died"},
|
||||
} {
|
||||
events = append(events, ResponsesEventToAnthropicEvents(evt, state)...)
|
||||
}
|
||||
events = append(events, FinalizeResponsesAnthropicStream(state)...)
|
||||
|
||||
assert.Equal(t, "recovered before the stream died", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
require.NotEmpty(t, events)
|
||||
assert.Equal(t, "message_stop", events[len(events)-1].Type)
|
||||
}
|
||||
|
||||
// The terminal walk only takes output_text parts of message items.
|
||||
func TestResponsesEventToAnthropicEvents_DeclinesTerminalRecoveryWithoutMessageText(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response *ResponsesResponse
|
||||
}{
|
||||
{"no response payload", nil},
|
||||
{"no message item", &ResponsesResponse{Status: "completed", Output: []ResponsesOutput{{Type: "reasoning"}}}},
|
||||
{"message without output_text", &ResponsesResponse{Status: "completed", Output: []ResponsesOutput{{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ResponsesContentPart{{Type: "refusal", Text: "refused"}},
|
||||
}}}},
|
||||
{"empty output_text", &ResponsesResponse{Status: "completed", Output: responsesMessageOutput("")}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: tc.response},
|
||||
)
|
||||
|
||||
assert.Empty(t, collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every message item of the terminal payload is recovered, and non-message
|
||||
// items between them are skipped without breaking the merged block.
|
||||
func TestResponsesEventToAnthropicEvents_RecoversEveryTerminalMessageItem(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
|
||||
Status: "completed",
|
||||
Output: []ResponsesOutput{
|
||||
{Type: "reasoning"},
|
||||
{Type: "message", Role: "assistant", Content: []ResponsesContentPart{{Type: "output_text", Text: "first"}}},
|
||||
{Type: "function_call", CallID: "call_1", Name: "lookup"},
|
||||
{Type: "message", Role: "assistant", Content: []ResponsesContentPart{{Type: "output_text", Text: "second"}}},
|
||||
},
|
||||
}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "firstsecond", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
|
||||
// Text that upstream delivers only on a done event still reaches the client
|
||||
// when a tool block is open, which the baseline dropped. The tool block is
|
||||
// closed first, so later block indices shift by one.
|
||||
func TestResponsesEventToAnthropicEvents_EmitsDoneTextWhileAToolBlockIsOpen(t *testing.T) {
|
||||
events := feedResponsesEvents(
|
||||
responsesCreated(),
|
||||
&ResponsesStreamEvent{
|
||||
Type: "response.output_item.added",
|
||||
OutputIndex: 0,
|
||||
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
|
||||
},
|
||||
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "spoken after the call"},
|
||||
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
|
||||
)
|
||||
|
||||
assert.Equal(t, "spoken after the call", collectAnthropicText(events))
|
||||
requireAnthropicBlockLifecycle(t, events)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,12 +20,13 @@ const (
|
||||
BetaFastMode = "fast-mode-2026-02-01"
|
||||
|
||||
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
|
||||
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
|
||||
BetaEffort = "effort-2025-11-24"
|
||||
BetaRedactThinking = "redact-thinking-2026-02-12"
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
|
||||
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
|
||||
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
|
||||
BetaEffort = "effort-2025-11-24"
|
||||
BetaRedactThinking = "redact-thinking-2026-02-12"
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
|
||||
BetaMidConversationOutputConfig = "mid-conversation-output-config-2026-07-01"
|
||||
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
|
||||
|
||||
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
|
||||
@@ -101,6 +102,7 @@ func FullClaudeCodeMimicryBetas() []string {
|
||||
BetaEffort,
|
||||
BetaContextManagement,
|
||||
BetaThinkingBindingControls,
|
||||
BetaMidConversationOutputConfig,
|
||||
BetaExtendedCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +83,10 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, capHint))
|
||||
if _, err := io.Copy(buf, req.Body); err != nil {
|
||||
raw, err := readRequestBodyChunks(req.Body, capHint, req.ContentLength)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := buf.Bytes()
|
||||
|
||||
enc := strings.ToLower(strings.TrimSpace(req.Header.Get("Content-Encoding")))
|
||||
if enc == "" || enc == "identity" {
|
||||
@@ -106,6 +105,56 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Read bounded chunks as bytes arrive, then assemble the exact-size result.
|
||||
// This avoids doubling large buffers or eagerly allocating an untrusted
|
||||
// Content-Length before the corresponding bytes have arrived.
|
||||
func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength int64) ([]byte, error) {
|
||||
capacity := initialCapacity
|
||||
var chunks [][]byte
|
||||
total := 0
|
||||
for {
|
||||
chunkCapacity := capacity
|
||||
if remaining := contentLength - int64(total); remaining >= 0 && remaining < int64(chunkCapacity) {
|
||||
chunkCapacity = int(remaining) + 1
|
||||
}
|
||||
chunk := make([]byte, chunkCapacity)
|
||||
n := 0
|
||||
var err error
|
||||
for n < len(chunk) && err == nil {
|
||||
var read int
|
||||
read, err = reader.Read(chunk[n:])
|
||||
n += read
|
||||
}
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
chunks = append(chunks, chunk[:n])
|
||||
total += n
|
||||
}
|
||||
if err != nil {
|
||||
if len(chunks) == 0 {
|
||||
return chunk[:0], nil
|
||||
}
|
||||
if len(chunks) == 1 {
|
||||
return chunks[0], nil
|
||||
}
|
||||
body := make([]byte, total)
|
||||
offset := 0
|
||||
for _, part := range chunks {
|
||||
offset += copy(body[offset:], part)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
if capacity < requestBodyReadMaxInitCap {
|
||||
capacity *= 2
|
||||
if capacity > requestBodyReadMaxInitCap {
|
||||
capacity = requestBodyReadMaxInitCap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLenientJSONRequestBodyWithPrealloc reads a request body and normalizes
|
||||
// JSON string control bytes before strict validation.
|
||||
func ReadLenientJSONRequestBodyWithPrealloc(req *http.Request, maxNormalizedBytes int64) ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
)
|
||||
|
||||
type bodyReaderOnly struct{ io.Reader }
|
||||
|
||||
func TestReadRequestBodyChunksPreservesContent(t *testing.T) {
|
||||
for _, size := range []int{0, 1, 512, 513, 1 << 20, (2 << 20) + 17} {
|
||||
body := bytes.Repeat([]byte{'x'}, size)
|
||||
for _, declared := range []int64{-1, 0, 1, int64(size), 1 << 40} {
|
||||
t.Run(fmt.Sprintf("size_%d_length_%d", size, declared), func(t *testing.T) {
|
||||
req := &http.Request{Body: io.NopCloser(bodyReaderOnly{bytes.NewReader(body)}), ContentLength: declared, Header: make(http.Header)}
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("body was lost or changed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyChunksPreservesReadErrors(t *testing.T) {
|
||||
for _, readErr := range []error{io.ErrUnexpectedEOF, errors.New("connection reset")} {
|
||||
reader := io.MultiReader(bytes.NewReader([]byte("partial")), iotest.ErrReader(readErr))
|
||||
req := &http.Request{Body: io.NopCloser(reader), ContentLength: 100, Header: make(http.Header)}
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if !errors.Is(err, readErr) || got != nil {
|
||||
t.Fatalf("truncated body accepted: body=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestBodyChunksPreservesMaxBytesReader(t *testing.T) {
|
||||
body := bytes.Repeat([]byte{'x'}, (2<<20)+1)
|
||||
req := &http.Request{Body: http.MaxBytesReader(nil, io.NopCloser(bytes.NewReader(body)), 2<<20), ContentLength: int64(len(body)), Header: make(http.Header)}
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
var limitErr *http.MaxBytesError
|
||||
if !errors.As(err, &limitErr) || limitErr.Limit != 2<<20 || got != nil {
|
||||
t.Fatalf("body limit changed: len=%d err=%v", len(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadLargeBody(b *testing.B) {
|
||||
for _, size := range []int{512 << 10, 69 << 20} {
|
||||
b.Run(fmt.Sprintf("bytes_%d", size), func(b *testing.B) {
|
||||
body := bytes.Repeat([]byte{'x'}, size)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(size))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := &http.Request{Body: io.NopCloser(bodyReaderOnly{bytes.NewReader(body)}), ContentLength: int64(size), Header: make(http.Header)}
|
||||
got, err := ReadRequestBodyWithPrealloc(req)
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
b.Fatalf("body changed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -2257,6 +2266,61 @@ func (r *accountRepository) ClearRateLimitIfObserved(ctx context.Context, id int
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetRateLimitedIfUnchanged atomically applies a rate-limit reset only while the
|
||||
// account still carries exactly the generation the caller observed: its
|
||||
// UpdatedAt row version, its RateLimitedAt and its RateLimitResetAt (nil means
|
||||
// that field is currently unset). It is the write-back CAS counterpart to
|
||||
// ClearRateLimitIfObserved: an async rate-limit reset (e.g. an Ollama Cloud
|
||||
// usage probe) must not overwrite a newer 429, an admin clear, a re-armed
|
||||
// generation, or a key/state change observed by another writer between the
|
||||
// caller's read and this write. The whole update is a single statement, so the
|
||||
// write itself is race-free. updated reports whether the write happened, and the
|
||||
// caller must ONLY send its scheduling notification when updated == true (this
|
||||
// method already performed the DB update; no further SetRateLimited call is
|
||||
// allowed, as a second unconditional write would reintroduce the race). No new
|
||||
// migration is required.
|
||||
func (r *accountRepository) SetRateLimitedIfUnchanged(
|
||||
ctx context.Context,
|
||||
id int64,
|
||||
expectedUpdatedAt time.Time,
|
||||
expectedLimitedAt, expectedResetAt *time.Time,
|
||||
newResetAt time.Time,
|
||||
) (bool, error) {
|
||||
preds := []dbpredicate.Account{dbaccount.IDEQ(id), dbaccount.UpdatedAtEQ(expectedUpdatedAt)}
|
||||
if expectedLimitedAt == nil {
|
||||
preds = append(preds, dbaccount.RateLimitedAtIsNil())
|
||||
} else {
|
||||
preds = append(preds, dbaccount.RateLimitedAtEQ(*expectedLimitedAt))
|
||||
}
|
||||
if expectedResetAt == nil {
|
||||
preds = append(preds, dbaccount.RateLimitResetAtIsNil())
|
||||
} else {
|
||||
preds = append(preds, dbaccount.RateLimitResetAtEQ(*expectedResetAt))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updated, err := r.client.Account.Update().
|
||||
Where(preds...).
|
||||
SetRateLimitedAt(now).
|
||||
SetRateLimitResetAt(newResetAt).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if updated == 0 {
|
||||
// The generation changed concurrently (cleared, re-armed, or the account
|
||||
// was otherwise updated elsewhere): do not announce anything, just
|
||||
// refresh the local scheduler snapshot.
|
||||
r.syncSchedulerAccountSnapshot(ctx, id)
|
||||
return false, nil
|
||||
}
|
||||
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
||||
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v", id, err)
|
||||
}
|
||||
r.syncSchedulerAccountSnapshot(ctx, id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error {
|
||||
if scope == "" {
|
||||
return nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAccountRepositorySetRateLimitedIfUnchanged exercises the atomic generation
|
||||
// CAS backing the Ollama Cloud usage probe write-back (SetRateLimitedIfUnchanged).
|
||||
// It must only write when the row still carries the exact UpdatedAt /
|
||||
// RateLimitedAt / RateLimitResetAt the caller observed, so a stale async reset can
|
||||
// never overwrite a newer 429, an admin clear, or a re-armed generation.
|
||||
func TestAccountRepositorySetRateLimitedIfUnchanged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
|
||||
makeAcct := func(name string) *service.Account {
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://www.ollama.com", "api_key": name},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Matching generation applies the new reset. ---
|
||||
acct := makeAcct("cas-match")
|
||||
base := time.Now().Add(5 * time.Second)
|
||||
require.NoError(t, repo.SetRateLimited(ctx, acct.ID, base))
|
||||
|
||||
cur, err := repo.GetByID(ctx, acct.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cur)
|
||||
require.NotNil(t, cur.RateLimitedAt)
|
||||
require.NotNil(t, cur.RateLimitResetAt)
|
||||
expUpdated, expLimited, expReset := cur.UpdatedAt, cur.RateLimitedAt, cur.RateLimitResetAt
|
||||
|
||||
newReset := time.Now().Add(2 * time.Hour)
|
||||
updated, err := repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, newReset)
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated, "a write matching the observed generation must apply")
|
||||
|
||||
after, err := repo.GetByID(ctx, acct.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.RateLimitResetAt)
|
||||
require.WithinDuration(t, newReset, *after.RateLimitResetAt, 2*time.Second)
|
||||
|
||||
// --- Stale UpdatedAt (row version moved by the write above) must NOT apply. ---
|
||||
stale := time.Now().Add(3 * time.Hour)
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, stale)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated, "a write whose row version moved on must not apply")
|
||||
|
||||
// --- Current UpdatedAt but OLD limited/reset must NOT apply: the rate-limit
|
||||
// generation was re-armed by the successful write, so the CAS must reject
|
||||
// purely on the limited/reset predicates (isolated from UpdatedAt). ---
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, expLimited, expReset, stale)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated, "re-armed limited/reset generation must not accept the old one even with the current row version")
|
||||
|
||||
// --- Current UpdatedAt but nil limited/reset must NOT apply against a set
|
||||
// generation (admin-clear expectation only matches an unset state). ---
|
||||
clearedReset := time.Now().Add(30 * time.Minute)
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, nil, nil, clearedReset)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated, "nil-limited expectation must not match a set generation")
|
||||
|
||||
// --- Stale UpdatedAt alone (with nil/nil) must also fail on the version. ---
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, nil, nil, clearedReset)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated, "stale row version must not apply even with nil limited/reset")
|
||||
|
||||
// --- Never-limited account: nil generation CAS applies. ---
|
||||
fresh := makeAcct("cas-fresh")
|
||||
freshCur, err := repo.GetByID(ctx, fresh.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, freshCur.RateLimitedAt)
|
||||
require.Nil(t, freshCur.RateLimitResetAt)
|
||||
firstReset := time.Now().Add(time.Hour)
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, freshCur.UpdatedAt, nil, nil, firstReset)
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated, "nil generation CAS must apply to a never-limited account")
|
||||
|
||||
// Admin-clear style: clear, then the old (set) generation must no longer match.
|
||||
require.NoError(t, repo.ClearRateLimit(ctx, fresh.ID))
|
||||
afterClear, err := repo.GetByID(ctx, fresh.ID)
|
||||
require.NoError(t, err)
|
||||
updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, afterClear.UpdatedAt, nil, nil, time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.True(t, updated, "cleared account with nil generation accepts a fresh write")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,6 @@ func (r *opsRepository) GetOpenAITokenStats(ctx context.Context, filter *service
|
||||
}
|
||||
|
||||
join, where, baseArgs, next := buildUsageWhere(dashboardFilter, dashboardFilter.StartTime, dashboardFilter.EndTime, 1)
|
||||
where += " AND ul.model LIKE 'gpt%'"
|
||||
|
||||
baseCTE := `
|
||||
WITH stats AS (
|
||||
|
||||
@@ -2,6 +2,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +12,81 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsRepositoryGetOpenAITokenStats_PlatformScope(t *testing.T) {
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(time.Hour)
|
||||
groupID := int64(9)
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
groupID *int64
|
||||
models []string
|
||||
}{
|
||||
{name: "all platforms", models: []string{"claude-sonnet-4", "deepseek-chat", "gemini-2.5-pro", "gpt-4o", "o3"}},
|
||||
{name: "anthropic group", platform: "anthropic", groupID: &groupID, models: []string{"claude-sonnet-4"}},
|
||||
{name: "gemini", platform: "gemini", models: []string{"gemini-2.5-pro"}},
|
||||
{name: "openai reasoning model", platform: "openai", models: []string{"o3"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
filter := &service.OpsOpenAITokenStatsFilter{
|
||||
TimeRange: "1h",
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Platform: tt.platform,
|
||||
GroupID: tt.groupID,
|
||||
TopN: 20,
|
||||
}
|
||||
|
||||
// Match the complete filtering clause in both queries so an implicit
|
||||
// model restriction cannot silently narrow the selected platform scope.
|
||||
where := "WHERE ul.created_at >= $1 AND ul.created_at < $2"
|
||||
args := []driver.Value{start, end}
|
||||
if tt.groupID != nil {
|
||||
where += " AND ul.group_id = $3"
|
||||
args = append(args, *tt.groupID)
|
||||
}
|
||||
if tt.platform != "" {
|
||||
placeholder := "$3"
|
||||
if tt.groupID != nil {
|
||||
placeholder = "$4"
|
||||
}
|
||||
where += " AND COALESCE(NULLIF(g.platform,''), a.platform) = " + placeholder
|
||||
args = append(args, tt.platform)
|
||||
}
|
||||
queryPrefix := regexp.QuoteMeta(where) + `\s+GROUP BY ul.model\s+\)`
|
||||
mock.ExpectQuery(queryPrefix + `\s+SELECT COUNT\(\*\) FROM stats`).
|
||||
WithArgs(args...).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(len(tt.models)))
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"model", "request_count", "avg_tokens_per_sec", "avg_first_token_ms",
|
||||
"total_output_tokens", "avg_duration_ms", "requests_with_first_token",
|
||||
})
|
||||
for _, model := range tt.models {
|
||||
rows.AddRow(model, int64(2), 10.0, 100.0, int64(20), int64(1000), int64(2))
|
||||
}
|
||||
mock.ExpectQuery(queryPrefix + `\s+SELECT model,`).
|
||||
WithArgs(append(args, 20)...).
|
||||
WillReturnRows(rows)
|
||||
|
||||
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.platform, resp.Platform)
|
||||
require.Equal(t, tt.groupID, resp.GroupID)
|
||||
require.Equal(t, int64(len(tt.models)), resp.Total)
|
||||
require.Len(t, resp.Items, len(tt.models))
|
||||
for i, model := range tt.models {
|
||||
require.Equal(t, model, resp.Items[i].Model)
|
||||
}
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsRepositoryGetOpenAITokenStats_PaginationMode(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
|
||||
@@ -93,6 +93,7 @@ WITH combined AS (
|
||||
COALESCE(NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
|
||||
ul.model AS model,
|
||||
ul.duration_ms AS duration_ms,
|
||||
ul.first_token_ms AS first_token_ms,
|
||||
NULL::INT AS status_code,
|
||||
NULL::BIGINT AS error_id,
|
||||
NULL::TEXT AS phase,
|
||||
@@ -117,6 +118,7 @@ WITH combined AS (
|
||||
COALESCE(NULLIF(o.platform, ''), NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
|
||||
o.model AS model,
|
||||
o.duration_ms AS duration_ms,
|
||||
o.time_to_first_token_ms AS first_token_ms,
|
||||
o.status_code AS status_code,
|
||||
o.id AS error_id,
|
||||
o.error_phase AS phase,
|
||||
@@ -152,6 +154,8 @@ WITH combined AS (
|
||||
// default
|
||||
case "duration_desc":
|
||||
sort = "ORDER BY duration_ms DESC NULLS LAST, created_at DESC"
|
||||
case "ttft_desc":
|
||||
sort = "ORDER BY first_token_ms DESC NULLS LAST, created_at DESC"
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("invalid sort")
|
||||
}
|
||||
@@ -166,6 +170,7 @@ SELECT
|
||||
platform,
|
||||
model,
|
||||
duration_ms,
|
||||
first_token_ms,
|
||||
status_code,
|
||||
error_id,
|
||||
phase,
|
||||
@@ -213,9 +218,10 @@ LIMIT $%d OFFSET $%d
|
||||
platform sql.NullString
|
||||
model sql.NullString
|
||||
|
||||
durationMs sql.NullInt64
|
||||
statusCode sql.NullInt64
|
||||
errorID sql.NullInt64
|
||||
durationMs sql.NullInt64
|
||||
firstTokenMs sql.NullInt64
|
||||
statusCode sql.NullInt64
|
||||
errorID sql.NullInt64
|
||||
|
||||
phase sql.NullString
|
||||
severity sql.NullString
|
||||
@@ -236,6 +242,7 @@ LIMIT $%d OFFSET $%d
|
||||
&platform,
|
||||
&model,
|
||||
&durationMs,
|
||||
&firstTokenMs,
|
||||
&statusCode,
|
||||
&errorID,
|
||||
&phase,
|
||||
@@ -257,12 +264,13 @@ LIMIT $%d OFFSET $%d
|
||||
Platform: strings.TrimSpace(platform.String),
|
||||
Model: strings.TrimSpace(model.String),
|
||||
|
||||
DurationMs: toIntPtr(durationMs),
|
||||
StatusCode: toIntPtr(statusCode),
|
||||
ErrorID: toInt64Ptr(errorID),
|
||||
Phase: phase.String,
|
||||
Severity: severity.String,
|
||||
Message: message.String,
|
||||
DurationMs: toIntPtr(durationMs),
|
||||
FirstTokenMs: toIntPtr(firstTokenMs),
|
||||
StatusCode: toIntPtr(statusCode),
|
||||
ErrorID: toInt64Ptr(errorID),
|
||||
Phase: phase.String,
|
||||
Severity: severity.String,
|
||||
Message: message.String,
|
||||
|
||||
UserID: toInt64Ptr(userID),
|
||||
APIKeyID: toInt64Ptr(apiKeyID),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpsRepositoryListRequestDetails_LatencySort(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sort string
|
||||
order string
|
||||
}{
|
||||
{name: "TTFT", sort: "ttft_desc", order: "first_token_ms DESC NULLS LAST, created_at DESC"},
|
||||
{name: "duration", sort: "duration_desc", order: "duration_ms DESC NULLS LAST, created_at DESC"},
|
||||
{name: "default", order: "created_at DESC"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, mock := newSQLMock(t)
|
||||
repo := &opsRepository{db: db}
|
||||
start := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(time.Hour)
|
||||
filter := &service.OpsRequestDetailFilter{
|
||||
StartTime: &start,
|
||||
EndTime: &end,
|
||||
Sort: tc.sort,
|
||||
Page: 2,
|
||||
PageSize: 10,
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(1\) FROM combined`).
|
||||
WithArgs(start, end).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(13))
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"kind", "created_at", "request_id", "platform", "model", "duration_ms", "first_token_ms",
|
||||
"status_code", "error_id", "phase", "severity", "message", "user_id", "api_key_id", "account_id", "group_id", "stream",
|
||||
}).
|
||||
AddRow("success", start, "req-slow", "openai", "gpt-5.5", 12000, 800, nil, nil, nil, nil, nil, 1, 2, 3, 4, true).
|
||||
AddRow("error", start, "req-zero", "openai", "gpt-5.5", 9000, 0, 502, 5, "upstream", "error", "failed", 1, 2, 3, 4, true).
|
||||
AddRow("success", start, "req-missing", "openai", "gpt-5.5", 5000, nil, nil, nil, nil, nil, nil, 1, 2, 3, 4, false)
|
||||
mock.ExpectQuery(`(?s)ul\.first_token_ms AS first_token_ms.*o\.time_to_first_token_ms AS first_token_ms.*SELECT.*duration_ms,\s+first_token_ms,.*ORDER BY `+tc.order+`\s+LIMIT \$3 OFFSET \$4`).
|
||||
WithArgs(start, end, 10, 10).
|
||||
WillReturnRows(rows)
|
||||
|
||||
items, total, err := repo.ListRequestDetails(context.Background(), filter)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 13, total)
|
||||
require.Len(t, items, 3)
|
||||
require.NotNil(t, items[0].FirstTokenMs)
|
||||
require.Equal(t, 800, *items[0].FirstTokenMs)
|
||||
require.Equal(t, 12000, *items[0].DurationMs)
|
||||
require.NotNil(t, items[1].FirstTokenMs)
|
||||
require.Zero(t, *items[1].FirstTokenMs)
|
||||
require.Nil(t, items[2].FirstTokenMs)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user